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