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:
parent
66ceb7e55f
commit
b4eded185f
18
apps/community-ui/.eslintrc.cjs
Normal file
18
apps/community-ui/.eslintrc.cjs
Normal 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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
34
apps/community-ui/Dockerfile
Normal file
34
apps/community-ui/Dockerfile
Normal 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;"]
|
||||||
6
apps/community-ui/component.yaml
Normal file
6
apps/community-ui/component.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
name: community-ui
|
||||||
|
type: app
|
||||||
|
port: 3002
|
||||||
|
path: apps/community-ui
|
||||||
|
stack: react
|
||||||
|
dependencies: []
|
||||||
13
apps/community-ui/index.html
Normal file
13
apps/community-ui/index.html
Normal 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>
|
||||||
26
apps/community-ui/nginx.conf
Normal file
26
apps/community-ui/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
40
apps/community-ui/package.json
Normal file
40
apps/community-ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/community-ui/postcss.config.cjs
Normal file
6
apps/community-ui/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
apps/community-ui/public/vite.svg
Normal file
1
apps/community-ui/public/vite.svg
Normal 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 |
111
apps/community-ui/src/App.tsx
Normal file
111
apps/community-ui/src/App.tsx
Normal 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;
|
||||||
6
apps/community-ui/src/index.css
Normal file
6
apps/community-ui/src/index.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/* Import design system tokens */
|
||||||
|
@import '@persona-community-5/ui/styles';
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
8
apps/community-ui/src/lib/logger.ts
Normal file
8
apps/community-ui/src/lib/logger.ts
Normal 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);
|
||||||
19
apps/community-ui/src/main.tsx
Normal file
19
apps/community-ui/src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
368
apps/community-ui/src/pages/CommunityPage.tsx
Normal file
368
apps/community-ui/src/pages/CommunityPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/community-ui/src/pages/LoginPage.tsx
Normal file
122
apps/community-ui/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
apps/community-ui/src/pages/PersonaDetailPage.tsx
Normal file
267
apps/community-ui/src/pages/PersonaDetailPage.tsx
Normal 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
1
apps/community-ui/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
16
apps/community-ui/tailwind.config.ts
Normal file
16
apps/community-ui/tailwind.config.ts
Normal 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;
|
||||||
25
apps/community-ui/tsconfig.json
Normal file
25
apps/community-ui/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
11
apps/community-ui/tsconfig.node.json
Normal file
11
apps/community-ui/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
30
apps/community-ui/vite.config.ts
Normal file
30
apps/community-ui/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user