build: Add community-ui React app with persona grid, OTP login, and detail pages

New React app at apps/community-ui (port 3002) using shared packages.
- OTP-only login flow via @persona-community-5/auth
- Responsive persona card grid with generating state overlays
- Create persona panel (Sheet sliding from right) with description, gender, name
- Detail page with banner header, avatar, image gallery with lightbox, video players
- Real-time updates via useEventChannel subscribed to channel:personas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rdev-worker 2026-02-24 08:22:12 +00:00
parent 66ceb7e55f
commit b4eded185f
20 changed files with 1128 additions and 0 deletions

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
};

View File

@ -0,0 +1,34 @@
# Build stage - using pnpm for workspace dependency resolution
FROM node:20-alpine AS build
# Install pnpm
RUN npm install -g pnpm
WORKDIR /workspace
# Copy workspace configuration files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
# Copy shared packages (required for workspace:* dependencies)
COPY packages/ ./packages/
# Copy the app component
COPY apps/community-ui/ ./apps/community-ui/
# Install dependencies using pnpm (resolves workspace:* correctly)
RUN pnpm install --frozen-lockfile || pnpm install
# Build the app
WORKDIR /workspace/apps/community-ui
RUN pnpm build
# Production stage
FROM nginx:alpine
# Copy built assets
COPY --from=build /workspace/apps/community-ui/dist /usr/share/nginx/html
COPY apps/community-ui/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3002
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,6 @@
name: community-ui
type: app
port: 3002
path: apps/community-ui
stack: react
dependencies: []

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>community-ui | persona-community-5</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,26 @@
server {
listen 3002;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

View File

@ -0,0 +1,40 @@
{
"name": "community-ui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --port 3002",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview --port 3002",
"format": "prettier --write src/"
},
"dependencies": {
"@persona-community-5/api-client": "workspace:*",
"@persona-community-5/auth": "workspace:*",
"@persona-community-5/layout": "workspace:*",
"@persona-community-5/logger": "workspace:*",
"@persona-community-5/realtime": "workspace:*",
"@persona-community-5/ui": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.4.38",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.3",
"vite": "^5.4.1"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,111 @@
import { useLocation, useNavigate, Routes, Route } from 'react-router-dom';
import { AuthProvider, useAuth, ProtectedRoute } from '@persona-community-5/auth';
import { DashboardShell, Sidebar, Header, type NavItem } from '@persona-community-5/layout';
import {
Home,
Loader2,
} from '@persona-community-5/ui';
import { LoginPage } from './pages/LoginPage';
import { CommunityPage } from './pages/CommunityPage';
import { PersonaDetailPage } from './pages/PersonaDetailPage';
const navItems: NavItem[] = [
{ label: 'Community', href: '/', icon: Home },
];
function LoadingScreen() {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" />
<p className="text-sm text-[var(--text-muted)]">Loading...</p>
</div>
</div>
);
}
function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
const { user, logout } = useAuth();
const itemsWithActive = navItems.map((item) => ({
...item,
active: location.pathname === item.href,
}));
const pageTitle = location.pathname.startsWith('/personas/') ? 'Persona' : 'Community';
return (
<DashboardShell
sidebar={
<Sidebar
logo={
<span className="font-semibold text-lg">Persona Community</span>
}
items={itemsWithActive}
onNavigate={(href) => navigate(href)}
footer={
<div className="flex flex-col gap-2">
<span className="text-sm text-[var(--text-muted)] truncate">
{user?.email || ''}
</span>
<button
type="button"
onClick={async () => { await logout(); navigate('/login'); }}
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] text-left"
>
Sign out
</button>
</div>
}
/>
}
header={
<Header title={pageTitle} />
}
>
<Routes>
<Route path="/" element={<CommunityPage />} />
<Route path="/personas/:id" element={<PersonaDetailPage />} />
</Routes>
</DashboardShell>
);
}
function AppRoutes() {
const location = useLocation();
const navigate = useNavigate();
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute
redirectTo="/login"
onRedirect={(path) => {
navigate(path, { state: { from: location.pathname }, replace: true });
}}
fallback={<LoadingScreen />}
>
<AppLayout />
</ProtectedRoute>
}
/>
</Routes>
);
}
function App() {
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
return (
<AuthProvider authBaseUrl={`${apiBaseUrl}/api/persona-api`}>
<AppRoutes />
</AuthProvider>
);
}
export default App;

View File

@ -0,0 +1,6 @@
/* Import design system tokens */
@import '@persona-community-5/ui/styles';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,8 @@
import { createLogger, installGlobalHandlers } from '@persona-community-5/logger';
export const logger = createLogger({
level: import.meta.env.DEV ? 'debug' : 'info',
service: 'community-ui',
});
installGlobalHandlers(logger);

View File

@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.tsx';
import './index.css';
import './lib/logger';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<App />
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,368 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@persona-community-5/auth';
import { useEventChannel, type ChannelEvent } from '@persona-community-5/realtime';
import {
Button,
Badge,
Textarea,
Input,
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
Plus,
ImageIcon,
Video,
Loader2,
} from '@persona-community-5/ui';
interface Persona {
id: string;
name: string;
handle: string;
gender: string;
description: string;
tags: string[];
anchor_url?: string;
avatar_url?: string;
banner_url?: string;
image_urls: string[];
video_urls: string[];
status: string;
created_at: string;
}
const STAGE_LABELS: Record<string, string> = {
spec: 'Crafting identity...',
anchor: 'Generating anchor...',
avatar: 'Creating avatar...',
banner: 'Designing banner...',
gallery_batch: 'Generating gallery...',
video: 'Producing videos...',
};
type Gender = 'woman' | 'man' | 'non_binary';
const GENDER_OPTIONS: { value: Gender; label: string }[] = [
{ value: 'woman', label: 'Female' },
{ value: 'man', label: 'Male' },
{ value: 'non_binary', label: 'Non-binary' },
];
function PersonaCard({
persona,
stage,
onClick,
}: {
persona: Persona;
stage?: string;
onClick: () => void;
}) {
const isGenerating = persona.status === 'pending' || persona.status === 'generating';
const tags = persona.tags?.slice(0, 4) ?? [];
return (
<button
type="button"
onClick={onClick}
className="group relative rounded-xl overflow-hidden border border-[var(--border-muted)] bg-[var(--surface-100)] text-left transition-all hover:border-[var(--border-hover)] hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
{/* Banner */}
<div className="relative h-40 bg-[var(--surface-200)]">
{persona.banner_url && (
<img
src={persona.banner_url}
alt=""
className="h-full w-full object-cover"
/>
)}
{/* Generating overlay */}
{isGenerating && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
<div className="h-6 w-6 rounded-full border-2 border-[var(--accent)] border-t-transparent animate-spin" />
<span className="text-xs text-white/80">
{stage ? STAGE_LABELS[stage] || stage : 'Generating...'}
</span>
</div>
</div>
)}
{/* Avatar overlay */}
<div className="absolute -bottom-5 left-4">
<div className="h-12 w-12 rounded-full border-2 border-[var(--background)] bg-[var(--surface-300)] overflow-hidden">
{persona.avatar_url ? (
<img
src={persona.avatar_url}
alt={persona.name}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full flex items-center justify-center text-[var(--text-muted)] text-sm font-medium">
{persona.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
</div>
</div>
{/* Content */}
<div className="p-4 pt-7">
<h3 className="text-sm font-semibold text-[var(--text-primary)] truncate">
{persona.name}
</h3>
<p className="text-xs text-[var(--text-muted)] truncate">@{persona.handle}</p>
{/* Tags */}
{tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0">
{tag}
</Badge>
))}
</div>
)}
{/* Media badges */}
<div className="mt-3 flex items-center gap-3 text-xs text-[var(--text-muted)]">
{persona.image_urls?.length > 0 && (
<span className="flex items-center gap-1">
<ImageIcon className="h-3 w-3" />
{persona.image_urls.length}
</span>
)}
{persona.video_urls?.length > 0 && (
<span className="flex items-center gap-1">
<Video className="h-3 w-3" />
{persona.video_urls.length}
</span>
)}
</div>
</div>
</button>
);
}
export function CommunityPage() {
const navigate = useNavigate();
const { getToken } = useAuth();
const [personas, setPersonas] = useState<Persona[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [description, setDescription] = useState('');
const [gender, setGender] = useState<Gender>('woman');
const [customName, setCustomName] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
const [stages, setStages] = useState<Record<string, string>>({});
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
const fetchPersonas = useCallback(async () => {
try {
const token = await getToken();
const res = await fetch(`${apiBaseUrl}/api/persona-api/personas?limit=100`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error('Failed to load personas');
const json = await res.json();
setPersonas(json.data ?? []);
} catch {
// silent
} finally {
setLoading(false);
}
}, [apiBaseUrl, getToken]);
useEffect(() => {
fetchPersonas();
}, [fetchPersonas]);
// Realtime: subscribe to channel:personas
const handleEvent = useCallback(
(event: ChannelEvent) => {
if (event.type === 'persona_updated') {
const updated = event.persona as Persona | undefined;
if (updated) {
setPersonas((prev) => {
const idx = prev.findIndex((p) => p.id === updated.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [updated, ...prev];
});
} else {
// Refetch if we don't have inline data
fetchPersonas();
}
}
if (event.type === 'job_update') {
const personaId = event.persona_id as string | undefined;
const stage = event.stage as string | undefined;
if (personaId && stage) {
setStages((prev) => ({ ...prev, [personaId]: stage }));
}
}
},
[fetchPersonas]
);
useEventChannel({
endpoint: `${apiBaseUrl}/api/persona-api/events`,
channel: 'channel:personas',
onEvent: handleEvent,
});
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
setCreating(true);
try {
const token = await getToken();
const res = await fetch(`${apiBaseUrl}/api/persona-api/personas`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
description,
gender,
custom_name: customName || undefined,
}),
});
if (!res.ok) {
const json = await res.json().catch(() => null);
throw new Error(json?.error?.message || 'Failed to create persona');
}
const json = await res.json();
const newPersona = json.data as Persona;
// Add immediately with generating state
setPersonas((prev) => [newPersona, ...prev]);
setCreateOpen(false);
setDescription('');
setCustomName('');
setGender('woman');
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Failed to create persona');
} finally {
setCreating(false);
}
};
return (
<div className="space-y-6">
{/* Top bar */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personas</h2>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Persona
</Button>
</div>
{/* Grid */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" />
</div>
) : personas.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
<p className="text-sm">No personas yet. Create your first one!</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{personas.map((persona) => (
<PersonaCard
key={persona.id}
persona={persona}
stage={stages[persona.id]}
onClick={() => navigate(`/personas/${persona.id}`)}
/>
))}
</div>
)}
{/* Create Panel (Sheet sliding from right) */}
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
<SheetContent side="right" className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Create Persona</SheetTitle>
<SheetDescription>Describe a new AI persona to generate.</SheetDescription>
</SheetHeader>
<form onSubmit={handleCreate} className="mt-6 space-y-5">
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">
Description
</label>
<Textarea
placeholder="Describe your persona..."
value={description}
onChange={(e) => setDescription(e.target.value)}
required
rows={4}
className="resize-none"
/>
</div>
{/* Gender pills */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">
Gender
</label>
<div className="flex gap-2">
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setGender(opt.value)}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
gender === opt.value
? 'bg-[var(--accent)] text-[var(--accent-foreground)] border-[var(--accent)]'
: 'bg-[var(--surface-100)] text-[var(--text-secondary)] border-[var(--border)] hover:border-[var(--border-hover)]'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Optional Name */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--text-primary)]">
Name <span className="text-[var(--text-muted)] font-normal">(optional)</span>
</label>
<Input
placeholder="Auto-generated if empty"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
/>
</div>
{createError && (
<p className="text-sm text-[var(--error)]">{createError}</p>
)}
<Button type="submit" className="w-full" disabled={creating || !description.trim()}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</form>
</SheetContent>
</Sheet>
</div>
);
}

View File

@ -0,0 +1,122 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@persona-community-5/auth';
import {
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
FormField,
Alert,
AlertDescription,
Loader2,
} from '@persona-community-5/ui';
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { sendOTP, loginWithOTP, isLoading } = useAuth();
const [generalError, setGeneralError] = useState<string | null>(null);
const [otpSent, setOtpSent] = useState(false);
const [otpEmail, setOtpEmail] = useState('');
const from = (location.state as { from?: string })?.from || '/';
const handleSendOTP = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setGeneralError(null);
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
try {
await sendOTP(email);
setOtpEmail(email);
setOtpSent(true);
} catch (error) {
setGeneralError(error instanceof Error ? error.message : 'Failed to send code');
}
};
const handleVerifyOTP = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setGeneralError(null);
const formData = new FormData(e.currentTarget);
const code = formData.get('code') as string;
try {
await loginWithOTP({ email: otpEmail, code });
navigate(from, { replace: true });
} catch (error) {
setGeneralError(error instanceof Error ? error.message : 'Invalid code');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--surface-100)] p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome</CardTitle>
<CardDescription>Sign in to the Persona Community</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{generalError && (
<Alert variant="destructive">
<AlertDescription>{generalError}</AlertDescription>
</Alert>
)}
{!otpSent && (
<form onSubmit={handleSendOTP} className="space-y-4">
<FormField
label="Email"
name="email"
type="email"
placeholder="you@example.com"
required
autoComplete="email"
autoFocus
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Send Code
</Button>
</form>
)}
{otpSent && (
<form onSubmit={handleVerifyOTP} className="space-y-4">
<p className="text-sm text-[var(--text-muted)]">
A 6-digit code was sent to <strong>{otpEmail}</strong>
</p>
<FormField
label="Code"
name="code"
type="text"
placeholder="000000"
required
autoComplete="one-time-code"
autoFocus
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Verify
</Button>
<button
type="button"
className="w-full text-sm text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
onClick={() => { setOtpSent(false); setGeneralError(null); }}
>
Use a different email
</button>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,267 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '@persona-community-5/auth';
import {
Badge,
Button,
ArrowLeft,
Loader2,
ChevronLeft,
ChevronRight,
X,
} from '@persona-community-5/ui';
interface Persona {
id: string;
name: string;
handle: string;
gender: string;
description: string;
tags: string[];
anchor_url?: string;
avatar_url?: string;
banner_url?: string;
image_urls: string[];
video_urls: string[];
status: string;
created_at: string;
}
const VIDEO_LABELS = [
'Smile Reveal',
'Personality Moment',
'Lifestyle',
'Invitation',
];
function ImageLightbox({
images,
initialIndex,
onClose,
}: {
images: string[];
initialIndex: number;
onClose: () => void;
}) {
const [index, setIndex] = useState(initialIndex);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') setIndex((i) => (i > 0 ? i - 1 : images.length - 1));
if (e.key === 'ArrowRight') setIndex((i) => (i < images.length - 1 ? i + 1 : 0));
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [images.length, onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/90">
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<X className="h-6 w-6 text-white" />
</button>
{images.length > 1 && (
<>
<button
type="button"
onClick={() => setIndex((i) => (i > 0 ? i - 1 : images.length - 1))}
className="absolute left-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<ChevronLeft className="h-6 w-6 text-white" />
</button>
<button
type="button"
onClick={() => setIndex((i) => (i < images.length - 1 ? i + 1 : 0))}
className="absolute right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
>
<ChevronRight className="h-6 w-6 text-white" />
</button>
</>
)}
<img
src={images[index]}
alt={`Image ${index + 1}`}
className="max-h-[90vh] max-w-[90vw] object-contain rounded-lg"
/>
<div className="absolute bottom-4 text-sm text-white/60">
{index + 1} / {images.length}
</div>
</div>
);
}
export function PersonaDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { getToken } = useAuth();
const [persona, setPersona] = useState<Persona | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const apiBaseUrl = import.meta.env.VITE_API_URL || '';
const fetchPersona = useCallback(async () => {
try {
const token = await getToken();
const res = await fetch(`${apiBaseUrl}/api/persona-api/personas/${id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
if (res.status === 404) throw new Error('Persona not found');
throw new Error('Failed to load persona');
}
const json = await res.json();
setPersona(json.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load persona');
} finally {
setLoading(false);
}
}, [apiBaseUrl, getToken, id]);
useEffect(() => {
fetchPersona();
}, [fetchPersona]);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" />
</div>
);
}
if (error || !persona) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<p className="text-sm text-[var(--error)]">{error || 'Persona not found'}</p>
<Button variant="outline" onClick={() => navigate('/')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
);
}
return (
<div className="space-y-8">
{/* Back button */}
<Button variant="ghost" onClick={() => navigate('/')} className="mb-2">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{/* Banner header */}
<div className="relative rounded-xl overflow-hidden">
<div className="h-48 md:h-64 bg-[var(--surface-200)]">
{persona.banner_url && (
<img
src={persona.banner_url}
alt=""
className="h-full w-full object-cover"
/>
)}
</div>
{/* Avatar centered */}
<div className="flex justify-center -mt-16">
<div className="relative h-32 w-32 rounded-full border-4 border-[var(--background)] bg-[var(--surface-300)] overflow-hidden">
{persona.avatar_url ? (
<img
src={persona.avatar_url}
alt={persona.name}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full flex items-center justify-center text-[var(--text-muted)] text-3xl font-bold">
{persona.name?.charAt(0)?.toUpperCase() || '?'}
</div>
)}
</div>
</div>
</div>
{/* Name + handle + tags */}
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{persona.name}</h1>
<p className="text-sm text-[var(--text-muted)]">@{persona.handle}</p>
{persona.tags?.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mt-3">
{persona.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
)}
<p className="text-sm text-[var(--text-secondary)] max-w-xl mx-auto mt-4">
{persona.description}
</p>
</div>
{/* Image gallery */}
{persona.image_urls?.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Gallery</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{persona.image_urls.map((url, i) => (
<button
key={url}
type="button"
onClick={() => setLightboxIndex(i)}
className="aspect-square rounded-lg overflow-hidden bg-[var(--surface-200)] hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
>
<img
src={url}
alt={`${persona.name} image ${i + 1}`}
className="h-full w-full object-cover"
/>
</button>
))}
</div>
</div>
)}
{/* Videos section */}
{persona.video_urls?.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Videos</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{persona.video_urls.map((url, i) => (
<div key={url} className="space-y-2">
<p className="text-sm font-medium text-[var(--text-secondary)]">
{VIDEO_LABELS[i] || `Video ${i + 1}`}
</p>
<div className="rounded-lg overflow-hidden bg-[var(--surface-200)]">
<video
src={url}
controls
className="w-full"
preload="metadata"
/>
</div>
</div>
))}
</div>
</div>
)}
{/* Lightbox */}
{lightboxIndex !== null && (
<ImageLightbox
images={persona.image_urls}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
);
}

1
apps/community-ui/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,16 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
'../../packages/ui/src/**/*.{js,ts,jsx,tsx}',
'../../packages/layout/src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,30 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3002,
proxy: {
'/api/persona-api/events': {
target: 'http://localhost:8001',
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
proxyRes.headers['cache-control'] = 'no-cache';
proxyRes.headers['x-accel-buffering'] = 'no';
}
});
},
},
'/api': {
target: 'http://localhost:8001',
changeOrigin: true,
},
},
},
preview: {
port: 3002,
},
});