diff --git a/apps/community-ui/.eslintrc.cjs b/apps/community-ui/.eslintrc.cjs new file mode 100644 index 0000000..4c99537 --- /dev/null +++ b/apps/community-ui/.eslintrc.cjs @@ -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 }, + ], + }, +}; diff --git a/apps/community-ui/Dockerfile b/apps/community-ui/Dockerfile new file mode 100644 index 0000000..30d8058 --- /dev/null +++ b/apps/community-ui/Dockerfile @@ -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;"] diff --git a/apps/community-ui/component.yaml b/apps/community-ui/component.yaml new file mode 100644 index 0000000..a5a632f --- /dev/null +++ b/apps/community-ui/component.yaml @@ -0,0 +1,6 @@ +name: community-ui +type: app +port: 3002 +path: apps/community-ui +stack: react +dependencies: [] diff --git a/apps/community-ui/index.html b/apps/community-ui/index.html new file mode 100644 index 0000000..cefceb4 --- /dev/null +++ b/apps/community-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + community-ui | persona-community-5 + + +
+ + + diff --git a/apps/community-ui/nginx.conf b/apps/community-ui/nginx.conf new file mode 100644 index 0000000..f608727 --- /dev/null +++ b/apps/community-ui/nginx.conf @@ -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; +} diff --git a/apps/community-ui/package.json b/apps/community-ui/package.json new file mode 100644 index 0000000..c3fbbb1 --- /dev/null +++ b/apps/community-ui/package.json @@ -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" + } +} diff --git a/apps/community-ui/postcss.config.cjs b/apps/community-ui/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/community-ui/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/community-ui/public/vite.svg b/apps/community-ui/public/vite.svg new file mode 100644 index 0000000..6a41099 --- /dev/null +++ b/apps/community-ui/public/vite.svg @@ -0,0 +1 @@ + diff --git a/apps/community-ui/src/App.tsx b/apps/community-ui/src/App.tsx new file mode 100644 index 0000000..06f4a0a --- /dev/null +++ b/apps/community-ui/src/App.tsx @@ -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 ( +
+
+ +

Loading...

+
+
+ ); +} + +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 ( + Persona Community + } + items={itemsWithActive} + onNavigate={(href) => navigate(href)} + footer={ +
+ + {user?.email || ''} + + +
+ } + /> + } + header={ +
+ } + > + + } /> + } /> + + + ); +} + +function AppRoutes() { + const location = useLocation(); + const navigate = useNavigate(); + + return ( + + } /> + { + navigate(path, { state: { from: location.pathname }, replace: true }); + }} + fallback={} + > + + + } + /> + + ); +} + +function App() { + const apiBaseUrl = import.meta.env.VITE_API_URL || ''; + + return ( + + + + ); +} + +export default App; diff --git a/apps/community-ui/src/index.css b/apps/community-ui/src/index.css new file mode 100644 index 0000000..ca3edbf --- /dev/null +++ b/apps/community-ui/src/index.css @@ -0,0 +1,6 @@ +/* Import design system tokens */ +@import '@persona-community-5/ui/styles'; + +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/apps/community-ui/src/lib/logger.ts b/apps/community-ui/src/lib/logger.ts new file mode 100644 index 0000000..2b83b15 --- /dev/null +++ b/apps/community-ui/src/lib/logger.ts @@ -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); diff --git a/apps/community-ui/src/main.tsx b/apps/community-ui/src/main.tsx new file mode 100644 index 0000000..7757f92 --- /dev/null +++ b/apps/community-ui/src/main.tsx @@ -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( + + + + + +); diff --git a/apps/community-ui/src/pages/CommunityPage.tsx b/apps/community-ui/src/pages/CommunityPage.tsx new file mode 100644 index 0000000..7ae390c --- /dev/null +++ b/apps/community-ui/src/pages/CommunityPage.tsx @@ -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 = { + 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 ( + + ); +} + +export function CommunityPage() { + const navigate = useNavigate(); + const { getToken } = useAuth(); + const [personas, setPersonas] = useState([]); + const [loading, setLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [description, setDescription] = useState(''); + const [gender, setGender] = useState('woman'); + const [customName, setCustomName] = useState(''); + const [createError, setCreateError] = useState(null); + const [stages, setStages] = useState>({}); + + 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 ( +
+ {/* Top bar */} +
+

Personas

+ +
+ + {/* Grid */} + {loading ? ( +
+ +
+ ) : personas.length === 0 ? ( +
+

No personas yet. Create your first one!

+
+ ) : ( +
+ {personas.map((persona) => ( + navigate(`/personas/${persona.id}`)} + /> + ))} +
+ )} + + {/* Create Panel (Sheet sliding from right) */} + + + + Create Persona + Describe a new AI persona to generate. + + +
+ {/* Description */} +
+ +