feat: contextual ask-question in help panel + user profile page with questions & posts
All checks were successful
Stuffle/nebula-os/pipeline/head This commit looks good

This commit is contained in:
2026-03-11 19:48:18 +05:30
parent 24c3baf79a
commit bb5231fc62
6 changed files with 463 additions and 3 deletions

View File

@@ -20,6 +20,7 @@ import BlogList from '@/pages/BlogList'
import BlogPost from '@/pages/BlogPost'
import QnAList from '@/pages/QnAList'
import QnAPost from '@/pages/QnAPost'
import Profile from '@/pages/Profile'
function App() {
return (
@@ -64,6 +65,7 @@ function App() {
<Route path="/blog/:postId" element={<BlogPost />} />
<Route path="/qna" element={<QnAList />} />
<Route path="/qna/:postId" element={<QnAPost />} />
<Route path="/profile" element={<Profile />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

View File

@@ -1,7 +1,103 @@
import { useState } from 'react'
import { useLocation, Link } from 'react-router-dom'
import { X, BookOpen, ArrowRight } from 'lucide-react'
import { X, BookOpen, ArrowRight, HelpCircle, Send, CheckCircle2, ChevronDown, ChevronUp } from 'lucide-react'
import { useMutation } from '@tanstack/react-query'
import { HELP_MAP } from './helpContent'
import { useHelp } from '@/hooks/useHelp'
import { useProfile } from '@/hooks/useProfile'
import { qnaApi } from '@/api/client'
function pageTag(pathname: string): string {
const seg = pathname.replace(/^\//, '').split('/')[0]
return seg || 'general'
}
function AskQuestionForm({ pathname }: { pathname: string }) {
const { profile } = useProfile()
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [postedId, setPostedId] = useState<string | null>(null)
const tag = pageTag(pathname)
const mutation = useMutation({
mutationFn: () => qnaApi.create({
title: title.trim(),
content: {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: title.trim() }] }],
},
author_name: profile.name,
tags: [tag],
}),
onSuccess: (post) => {
setPostedId(post.id)
setTitle('')
},
})
if (postedId) {
return (
<div className="ne-HelpPanel__ask-success">
<CheckCircle2 size={14} style={{ color: 'var(--nbl-green)', flexShrink: 0 }} />
<span>Question posted!</span>
<Link
to={`/qna/${postedId}`}
className="ne-HelpPanel__ask-view-link"
>
View it <ArrowRight size={10} />
</Link>
<button
onClick={() => { setPostedId(null); setOpen(false) }}
className="ne-HelpPanel__ask-reset"
aria-label="Ask another"
>
Ask another
</button>
</div>
)
}
return (
<div className="ne-HelpPanel__ask-wrap">
<button
onClick={() => setOpen(o => !o)}
className="ne-HelpPanel__ask-trigger"
>
<HelpCircle size={12} />
<span>Ask a question about {tag}</span>
{open ? <ChevronUp size={11} style={{ marginLeft: 'auto' }} /> : <ChevronDown size={11} style={{ marginLeft: 'auto' }} />}
</button>
{open && (
<div className="ne-HelpPanel__ask-form">
<input
autoFocus
value={title}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && title.trim() && mutation.mutate()}
placeholder="What's your question?"
className="ne-HelpPanel__ask-input"
/>
<div className="ne-HelpPanel__ask-meta">
<span className="ne-HelpPanel__ask-tag">#{tag}</span>
<button
onClick={() => mutation.mutate()}
disabled={!title.trim() || mutation.isPending}
className="ne-HelpPanel__ask-submit"
>
{mutation.isPending ? '…' : <Send size={11} />}
{mutation.isPending ? 'Posting' : 'Post'}
</button>
</div>
{mutation.error && (
<p className="ne-HelpPanel__ask-error">{String(mutation.error)}</p>
)}
</div>
)}
</div>
)
}
export function HelpPanel() {
const { helpOpen, closeHelp } = useHelp()
@@ -84,7 +180,8 @@ export function HelpPanel() {
</div>
<div className="ne-HelpPanel__footer">
<Link to="/docs" onClick={closeHelp} className="nbl-btn-ghost" style={{ fontSize: '0.75rem', width: '100%', justifyContent: 'center' }}>
<AskQuestionForm pathname={pathname} />
<Link to="/docs" onClick={closeHelp} className="nbl-btn-ghost" style={{ fontSize: '0.75rem', width: '100%', justifyContent: 'center', marginTop: '0.5rem' }}>
<BookOpen size={12} />
Full documentation
</Link>

View File

@@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { useTheme } from '@/theme'
import { useHelp } from '@/hooks/useHelp'
import { useProfile } from '@/hooks/useProfile'
import { ToolsPanel } from './ToolsPanel'
import {
Activity,
@@ -48,6 +49,7 @@ export function Navbar() {
const { pathname } = useLocation()
const { theme, toggleWithTransition } = useTheme()
const { openHelp, helpOpen } = useHelp()
const { profile } = useProfile()
const [overflowOpen, setOverflowOpen] = useState(false)
const overflowRef = useRef<HTMLDivElement>(null)
const [toolsOpen, setToolsOpen] = useState(false)
@@ -157,6 +159,17 @@ export function Navbar() {
>
<CircleHelp size={14} />
</button>
<Link
to="/profile"
className="ne-Navbar__profile-btn"
aria-label="Your profile"
title={profile.name}
>
{profile.avatar
? <img src={profile.avatar} alt={profile.name} className="ne-Navbar__profile-img" />
: <span className="ne-Navbar__profile-initials">{profile.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}</span>
}
</Link>
<button
onClick={e => {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()

View File

@@ -0,0 +1,40 @@
import { useState, useCallback } from 'react'
export interface UserProfile {
name: string
avatar: string | null
}
const STORAGE_KEY = 'nebula_user_profile'
function load(): UserProfile {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) return JSON.parse(raw) as UserProfile
} catch {
// ignore parse errors
}
return { name: 'Anonymous', avatar: null }
}
function save(profile: UserProfile): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(profile))
} catch {
// ignore storage errors
}
}
export function useProfile() {
const [profile, setProfileState] = useState<UserProfile>(load)
const setProfile = useCallback((updates: Partial<UserProfile>) => {
setProfileState(prev => {
const next = { ...prev, ...updates }
save(next)
return next
})
}, [])
return { profile, setProfile }
}

View File

@@ -1017,6 +1017,10 @@ button, a, [role="button"], label, select, summary {
.ne-Navbar__right { display: flex; align-items: center; gap: 0.625rem; flex-shrink: 0; }
.ne-Navbar__status { display: flex; align-items: center; gap: 0.375rem; font-size: 0.6875rem; font-family: var(--font-mono); color: var(--nbl-green); }
.ne-Navbar__theme-btn { padding: 0.375rem 0.5rem; border: 1px solid var(--nbl-border); border-radius: 0.5rem; }
.ne-Navbar__profile-btn { display: flex; align-items: center; justify-content: center; width: 1.875rem; height: 1.875rem; border-radius: 9999px; background: var(--nbl-nebula-bg); border: 1.5px solid var(--nbl-nebula-border); overflow: hidden; flex-shrink: 0; text-decoration: none; transition: opacity 120ms; }
.ne-Navbar__profile-btn:hover { opacity: 0.8; }
.ne-Navbar__profile-img { width: 100%; height: 100%; object-fit: cover; }
.ne-Navbar__profile-initials { font-size: 0.625rem; font-family: var(--font-mono); font-weight: 700; color: var(--nbl-nebula); line-height: 1; }
/* ─── ne-Navbar tools trigger ───────────────────────────────────────────────── */
.ne-Navbar__tools-wrap { position: relative; }
@@ -1553,7 +1557,72 @@ button, a, [role="button"], label, select, summary {
.ne-HelpPanel__empty { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; text-align: center; padding: 2rem 1rem; }
.ne-HelpPanel__empty-text { font-size: 0.75rem; font-family: var(--font-mono); color: var(--nbl-text-faint); margin: 0; }
.ne-HelpPanel__footer { padding: 0.75rem 1rem; border-top: 1px solid var(--nbl-border); flex-shrink: 0; }
.ne-HelpPanel__footer { padding: 0.75rem 1rem; border-top: 1px solid var(--nbl-border); flex-shrink: 0; display: flex; flex-direction: column; gap: 0; }
/* ── HelpPanel inline ask-question form ──────────────────────────────────── */
.ne-HelpPanel__ask-wrap { display: flex; flex-direction: column; gap: 0; }
.ne-HelpPanel__ask-trigger {
display: flex; align-items: center; gap: 0.4rem;
width: 100%; padding: 0.4rem 0.5rem; border-radius: 0.375rem;
font-size: 0.6875rem; font-family: var(--font-mono); color: var(--nbl-text-faint);
background: transparent; border: 1px solid transparent;
cursor: pointer; transition: background 120ms, color 120ms, border-color 120ms;
text-align: left;
}
.ne-HelpPanel__ask-trigger:hover { background: var(--nbl-bg-card); border-color: var(--nbl-border-subtle); color: var(--nbl-text-secondary); }
.ne-HelpPanel__ask-form { display: flex; flex-direction: column; gap: 0.375rem; margin-top: 0.375rem; padding: 0.5rem; background: var(--nbl-bg-card); border: 1px solid var(--nbl-border-subtle); border-radius: 0.5rem; }
.ne-HelpPanel__ask-input {
width: 100%; padding: 0.375rem 0.5rem; background: var(--nbl-bg-base);
border: 1px solid var(--nbl-border); border-radius: 0.375rem;
font-size: 0.6875rem; font-family: var(--font-mono); color: var(--nbl-text-primary);
outline: none; transition: border-color 120ms;
}
.ne-HelpPanel__ask-input:focus { border-color: var(--nbl-nebula-border); }
.ne-HelpPanel__ask-input::placeholder { color: var(--nbl-text-ghost); }
.ne-HelpPanel__ask-meta { display: flex; align-items: center; justify-content: space-between; }
.ne-HelpPanel__ask-tag { font-size: 0.5625rem; font-family: var(--font-mono); color: var(--nbl-nebula); background: var(--nbl-nebula-bg); padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
.ne-HelpPanel__ask-submit {
display: flex; align-items: center; gap: 0.3rem;
padding: 0.25rem 0.625rem; border-radius: 0.3rem;
font-size: 0.625rem; font-family: var(--font-mono); font-weight: 600;
background: var(--nbl-nebula-bg); border: 1px solid var(--nbl-nebula-border);
color: var(--nbl-nebula); cursor: pointer; transition: opacity 120ms;
}
.ne-HelpPanel__ask-submit:disabled { opacity: 0.4; cursor: not-allowed; }
.ne-HelpPanel__ask-submit:not(:disabled):hover { opacity: 0.8; }
.ne-HelpPanel__ask-error { font-size: 0.625rem; font-family: var(--font-mono); color: var(--nbl-red); margin: 0; }
.ne-HelpPanel__ask-success {
display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;
padding: 0.4rem 0.5rem; background: var(--nbl-green-bg); border: 1px solid var(--nbl-green-border);
border-radius: 0.375rem; font-size: 0.625rem; font-family: var(--font-mono); color: var(--nbl-green);
}
.ne-HelpPanel__ask-view-link {
display: inline-flex; align-items: center; gap: 0.2rem;
color: var(--nbl-cyan); text-decoration: underline; font-size: 0.625rem; font-family: var(--font-mono);
}
.ne-HelpPanel__ask-reset { margin-left: auto; font-size: 0.5625rem; font-family: var(--font-mono); color: var(--nbl-text-faint); background: none; border: none; cursor: pointer; text-decoration: underline; }
.ne-HelpPanel__ask-reset:hover { color: var(--nbl-text-secondary); }
/* ── User Profile page ───────────────────────────────────────────────────── */
.ne-Profile { max-width: 52rem; }
.ne-Profile__hero { display: flex; align-items: center; gap: 1.25rem; padding: 1.5rem; background: var(--nbl-bg-card); border: 1px solid var(--nbl-border); border-radius: 0.875rem; margin-bottom: 1.5rem; }
.ne-Profile__avatar { width: 3.5rem; height: 3.5rem; border-radius: 9999px; background: var(--nbl-nebula-bg); border: 2px solid var(--nbl-nebula-border); display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 1.25rem; font-family: var(--font-mono); color: var(--nbl-nebula); font-weight: 700; cursor: pointer; overflow: hidden; transition: opacity 120ms; }
.ne-Profile__avatar:hover { opacity: 0.8; }
.ne-Profile__avatar img { width: 100%; height: 100%; object-fit: cover; }
.ne-Profile__meta { flex: 1; min-width: 0; }
.ne-Profile__name-row { display: flex; align-items: center; gap: 0.625rem; margin-bottom: 0.25rem; }
.ne-Profile__name { font-size: 1.125rem; font-weight: 700; color: var(--nbl-text-heading); }
.ne-Profile__name-edit { font-size: 0.6875rem; font-family: var(--font-mono); color: var(--nbl-text-faint); background: none; border: none; cursor: pointer; padding: 0.1rem 0.375rem; border-radius: 0.25rem; transition: background 100ms, color 100ms; }
.ne-Profile__name-edit:hover { background: var(--nbl-bg-elevated); color: var(--nbl-text-secondary); }
.ne-Profile__name-input { font-size: 1rem; font-weight: 600; color: var(--nbl-text-primary); background: var(--nbl-bg-elevated); border: 1px solid var(--nbl-nebula-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; outline: none; }
.ne-Profile__stats { display: flex; gap: 1.5rem; }
.ne-Profile__stat { display: flex; flex-direction: column; align-items: flex-start; }
.ne-Profile__stat-value { font-size: 1.25rem; font-weight: 700; color: var(--nbl-text-heading); line-height: 1; }
.ne-Profile__stat-label { font-size: 0.625rem; font-family: var(--font-mono); color: var(--nbl-text-faint); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 0.2rem; }
.ne-Profile__tabs { display: flex; gap: 0; border-bottom: 1px solid var(--nbl-border); margin-bottom: 1.25rem; }
.ne-Profile__tab { padding: 0.5rem 1rem; font-size: 0.8125rem; font-family: var(--font-mono); color: var(--nbl-text-faint); background: none; border: none; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color 120ms, border-color 120ms; }
.ne-Profile__tab:hover { color: var(--nbl-text-secondary); }
.ne-Profile__tab--active { color: var(--nbl-text-primary); border-bottom-color: var(--nbl-nebula); }
/* Navbar help button active state */
.ne-Navbar__help-btn--active { background: var(--nbl-nebula-bg) !important; color: var(--nbl-nebula) !important; }

View File

@@ -0,0 +1,239 @@
import { useState, useRef, type KeyboardEvent } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { User, HelpCircle, FileText, CheckCircle2, MessageSquare } from 'lucide-react'
import { qnaApi, blogApi } from '@/api/client'
import { useProfile } from '@/hooks/useProfile'
import { PageShell, PageHeader, EmptyState, LoadingState, ErrorState, Badge } from '@/components/nebula-ux'
import RelativeTime from '@/components/RelativeTime'
type Tab = 'questions' | 'posts'
export default function Profile() {
const navigate = useNavigate()
const { profile, setProfile } = useProfile()
const [tab, setTab] = useState<Tab>('questions')
const [editingName, setEditingName] = useState(false)
const [draftName, setDraftName] = useState(profile.name)
const inputRef = useRef<HTMLInputElement>(null)
const { data: qnaData, isLoading: qnaLoading, error: qnaError } = useQuery({
queryKey: ['profile-qna', profile.name],
queryFn: () => qnaApi.list({ page_size: 50 }),
enabled: !!profile.name,
})
const { data: blogData, isLoading: blogLoading, error: blogError } = useQuery({
queryKey: ['profile-blog', profile.name],
queryFn: () => blogApi.list({ page_size: 50 }),
enabled: !!profile.name,
})
const myQuestions = (qnaData?.items ?? []).filter(
q => q.author_name === profile.name
)
const myPosts = (blogData?.items ?? []).filter(
p => p.author_name === profile.name
)
function commitName() {
const trimmed = draftName.trim()
if (trimmed) setProfile({ name: trimmed })
setEditingName(false)
}
function onNameKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') commitName()
if (e.key === 'Escape') { setDraftName(profile.name); setEditingName(false) }
}
const initials = profile.name
.split(' ')
.map(w => w[0])
.join('')
.slice(0, 2)
.toUpperCase()
const statusVariantsQnA: Record<string, 'green' | 'amber' | 'muted'> = {
open: 'amber', answered: 'green', closed: 'muted',
}
const statusVariantsBlog: Record<string, 'green' | 'amber' | 'muted'> = {
published: 'green', draft: 'amber', archived: 'muted',
}
return (
<PageShell page="profile">
<PageHeader
icon={<User size={20} />}
title="Profile"
subtitle="Your activity on NebulaOS"
/>
<div className="ne-Profile">
{/* ── Hero card ───────────────────────────────── */}
<div className="ne-Profile__hero">
<div
className="ne-Profile__avatar"
title="Click to change avatar URL"
onClick={() => {
const url = window.prompt('Avatar image URL (leave blank to clear):', profile.avatar ?? '')
if (url !== null) setProfile({ avatar: url || null })
}}
>
{profile.avatar
? <img src={profile.avatar} alt={profile.name} />
: initials}
</div>
<div className="ne-Profile__meta">
<div className="ne-Profile__name-row">
{editingName ? (
<input
ref={inputRef}
autoFocus
className="ne-Profile__name-input"
value={draftName}
onChange={e => setDraftName(e.target.value)}
onBlur={commitName}
onKeyDown={onNameKeyDown}
/>
) : (
<>
<span className="ne-Profile__name">{profile.name}</span>
<button
className="ne-Profile__name-edit"
onClick={() => { setDraftName(profile.name); setEditingName(true) }}
>
Edit
</button>
</>
)}
</div>
<div className="ne-Profile__stats">
<div className="ne-Profile__stat">
<span className="ne-Profile__stat-value">{myQuestions.length}</span>
<span className="ne-Profile__stat-label">Questions</span>
</div>
<div className="ne-Profile__stat">
<span className="ne-Profile__stat-value">{myPosts.length}</span>
<span className="ne-Profile__stat-label">Blog posts</span>
</div>
</div>
</div>
</div>
{/* ── Tabs ─────────────────────────────────────── */}
<div className="ne-Profile__tabs">
<button
className={`ne-Profile__tab${tab === 'questions' ? ' ne-Profile__tab--active' : ''}`}
onClick={() => setTab('questions')}
>
<HelpCircle size={13} style={{ display: 'inline', marginRight: '0.375rem' }} />
Questions ({myQuestions.length})
</button>
<button
className={`ne-Profile__tab${tab === 'posts' ? ' ne-Profile__tab--active' : ''}`}
onClick={() => setTab('posts')}
>
<FileText size={13} style={{ display: 'inline', marginRight: '0.375rem' }} />
Blog Posts ({myPosts.length})
</button>
</div>
{/* ── Questions tab ────────────────────────────── */}
{tab === 'questions' && (
<>
{qnaLoading && <LoadingState message="Loading questions…" />}
{qnaError && <ErrorState message={String(qnaError)} />}
{!qnaLoading && !qnaError && myQuestions.length === 0 && (
<EmptyState
icon={<HelpCircle size={28} />}
message={`No questions yet from "${profile.name}". Open the help panel (?) on any page to ask contextually.`}
/>
)}
<div className="grid gap-3">
{myQuestions.map(post => (
<button
key={post.id}
onClick={() => navigate(`/qna/${post.id}`)}
className="w-full text-left p-4 rounded-xl border border-white/10 bg-zinc-900/40 hover:bg-zinc-800/60 transition-colors"
>
<div className="flex items-start gap-4">
<div className="flex flex-col items-center gap-1 min-w-[48px] text-center">
<div className="text-base font-semibold text-zinc-300">{post.upvotes}</div>
<div className="text-[10px] text-zinc-600 uppercase">votes</div>
<div className={`flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded ${post.accepted_answer_id ? 'bg-green-500/10 text-green-400' : post.answer_count > 0 ? 'bg-white/5 text-zinc-400' : 'text-zinc-600'
}`}>
{post.accepted_answer_id ? <CheckCircle2 size={10} /> : <MessageSquare size={10} />}
{post.answer_count}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-semibold text-zinc-100 truncate">{post.title}</h3>
<Badge variant={statusVariantsQnA[post.status] || 'muted'} size="sm">{post.status}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-zinc-500">
<RelativeTime isoString={post.created_at} />
<span>{post.view_count} views</span>
{post.tags.map(t => (
<span key={t} className="px-1.5 py-0.5 rounded bg-indigo-500/10 text-indigo-400 text-[10px]">{t}</span>
))}
</div>
</div>
</div>
</button>
))}
</div>
</>
)}
{/* ── Blog posts tab ───────────────────────────── */}
{tab === 'posts' && (
<>
{blogLoading && <LoadingState message="Loading posts…" />}
{blogError && <ErrorState message={String(blogError)} />}
{!blogLoading && !blogError && myPosts.length === 0 && (
<EmptyState
icon={<FileText size={28} />}
message={`No blog posts yet from "${profile.name}". Head to Blog to create one.`}
/>
)}
<div className="grid gap-4">
{myPosts.map(post => (
<button
key={post.id}
onClick={() => navigate(`/blog/${post.id}`)}
className="w-full text-left p-4 rounded-xl border border-white/10 bg-zinc-900/40 hover:bg-zinc-800/60 transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-semibold text-zinc-100 truncate">{post.title}</h3>
<Badge variant={statusVariantsBlog[post.status] || 'muted'}>{post.status}</Badge>
</div>
{post.excerpt && (
<p className="text-xs text-zinc-400 line-clamp-2 mb-1">{post.excerpt}</p>
)}
<div className="flex items-center gap-3 text-xs text-zinc-500">
<RelativeTime isoString={post.created_at} />
<span>{post.view_count} views</span>
<span>{post.comment_count} comments</span>
{post.tags.map(t => (
<span key={t} className="px-1.5 py-0.5 rounded bg-white/5 text-zinc-500 text-[10px]">{t}</span>
))}
</div>
</div>
{post.cover_image_url && (
<img src={post.cover_image_url} alt="" className="w-14 h-14 rounded-lg object-cover flex-shrink-0" />
)}
</div>
</button>
))}
</div>
</>
)}
</div>
</PageShell>
)
}