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 (
+
+ );
+}
+
+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.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/community-ui/src/pages/LoginPage.tsx b/apps/community-ui/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..8be470c
--- /dev/null
+++ b/apps/community-ui/src/pages/LoginPage.tsx
@@ -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(null);
+ const [otpSent, setOtpSent] = useState(false);
+ const [otpEmail, setOtpEmail] = useState('');
+
+ const from = (location.state as { from?: string })?.from || '/';
+
+ const handleSendOTP = async (e: React.FormEvent) => {
+ 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) => {
+ 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 (
+
+
+
+ Welcome
+ Sign in to the Persona Community
+
+
+
+ {generalError && (
+
+ {generalError}
+
+ )}
+
+ {!otpSent && (
+
+ )}
+
+ {otpSent && (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/community-ui/src/pages/PersonaDetailPage.tsx b/apps/community-ui/src/pages/PersonaDetailPage.tsx
new file mode 100644
index 0000000..6749cb1
--- /dev/null
+++ b/apps/community-ui/src/pages/PersonaDetailPage.tsx
@@ -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 (
+
+
+
+ {images.length > 1 && (
+ <>
+
+
+ >
+ )}
+
+

+
+
+ {index + 1} / {images.length}
+
+
+ );
+}
+
+export function PersonaDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { getToken } = useAuth();
+ const [persona, setPersona] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [lightboxIndex, setLightboxIndex] = useState(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 (
+
+
+
+ );
+ }
+
+ if (error || !persona) {
+ return (
+
+
{error || 'Persona not found'}
+
+
+ );
+ }
+
+ return (
+
+ {/* Back button */}
+
+
+ {/* Banner header */}
+
+
+ {persona.banner_url && (
+

+ )}
+
+
+ {/* Avatar centered */}
+
+
+ {persona.avatar_url ? (
+

+ ) : (
+
+ {persona.name?.charAt(0)?.toUpperCase() || '?'}
+
+ )}
+
+
+
+
+ {/* Name + handle + tags */}
+
+
{persona.name}
+
@{persona.handle}
+ {persona.tags?.length > 0 && (
+
+ {persona.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {persona.description}
+
+
+
+ {/* Image gallery */}
+ {persona.image_urls?.length > 0 && (
+
+
Gallery
+
+ {persona.image_urls.map((url, i) => (
+
+ ))}
+
+
+ )}
+
+ {/* Videos section */}
+ {persona.video_urls?.length > 0 && (
+
+
Videos
+
+ {persona.video_urls.map((url, i) => (
+
+
+ {VIDEO_LABELS[i] || `Video ${i + 1}`}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Lightbox */}
+ {lightboxIndex !== null && (
+
setLightboxIndex(null)}
+ />
+ )}
+
+ );
+}
diff --git a/apps/community-ui/src/vite-env.d.ts b/apps/community-ui/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/community-ui/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/community-ui/tailwind.config.ts b/apps/community-ui/tailwind.config.ts
new file mode 100644
index 0000000..b73e78c
--- /dev/null
+++ b/apps/community-ui/tailwind.config.ts
@@ -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;
diff --git a/apps/community-ui/tsconfig.json b/apps/community-ui/tsconfig.json
new file mode 100644
index 0000000..a7fc6fb
--- /dev/null
+++ b/apps/community-ui/tsconfig.json
@@ -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" }]
+}
diff --git a/apps/community-ui/tsconfig.node.json b/apps/community-ui/tsconfig.node.json
new file mode 100644
index 0000000..97ede7e
--- /dev/null
+++ b/apps/community-ui/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/apps/community-ui/vite.config.ts b/apps/community-ui/vite.config.ts
new file mode 100644
index 0000000..e1ce864
--- /dev/null
+++ b/apps/community-ui/vite.config.ts
@@ -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,
+ },
+});