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
All checks were successful
Stuffle/nebula-os/pipeline/head This commit looks good
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
40
webapp/src/hooks/useProfile.ts
Normal file
40
webapp/src/hooks/useProfile.ts
Normal 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 }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
239
webapp/src/pages/Profile.tsx
Normal file
239
webapp/src/pages/Profile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user