This commit is contained in:
2026-01-27 17:40:37 +01:00
parent 82947a7bd6
commit adc2cd572a
55 changed files with 4145 additions and 101 deletions

View File

@@ -12,7 +12,7 @@ server {
# Proxy API requests to the backend service
location /api/ {
proxy_pass http://backend:8000/;
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -19,10 +27,13 @@
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,35 +1,25 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import { Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/layout/Layout';
import Dashboard from './pages/Dashboard';
import AssetsPage from './pages/Assets';
import ScriptPage from './pages/ScriptPage';
import AssemblyPage from './pages/AssemblyPage';
import SettingsPage from './pages/SettingsPage';
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="assets" element={<AssetsPage />} />
<Route path="script" element={<ScriptPage />} />
<Route path="assembly" element={<AssemblyPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
);
}
export default App
export default App;

View File

@@ -0,0 +1,20 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import TopNav from './TopNav';
const Layout = () => {
return (
<div className="flex h-screen bg-background text-foreground overflow-hidden font-sans">
<Sidebar />
<div className="flex-1 flex flex-col min-w-0">
<TopNav />
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
<Outlet />
</main>
</div>
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,62 @@
import { NavLink } from 'react-router-dom';
import {
LayoutGrid,
Layers,
Database,
FileText,
PanelLeftClose,
Settings
} from 'lucide-react';
import { cn } from '../../lib/utils';
const Sidebar = () => {
const navItems = [
{ icon: LayoutGrid, label: 'Flow Dashboard', path: '/' },
{ icon: Layers, label: 'Shot Assembly', path: '/assembly' },
{ icon: Database, label: 'Asset Library', path: '/assets' },
{ icon: FileText, label: 'Master Script', path: '/script' },
{ icon: Settings, label: 'Settings', path: '/settings' },
];
return (
<aside className="w-64 border-r border-border bg-card flex flex-col">
<div className="h-14 flex items-center px-6 border-b border-border">
<div className="flex items-center gap-2 font-bold text-xl tracking-tight">
<div className="w-6 h-6 rounded bg-primary flex items-center justify-center text-primary-foreground text-xs">
<LayoutGrid size={16} />
</div>
<span className="text-foreground">AUTEUR</span>
<span className="text-muted-foreground">.FLOW</span>
</div>
</div>
<nav className="flex-1 py-6 px-3 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => cn(
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary border-l-2 border-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-border">
<button className="flex items-center gap-3 px-3 py-2 w-full text-muted-foreground hover:text-foreground text-sm font-medium transition-colors">
<PanelLeftClose size={18} />
<span>Collapse</span>
</button>
</div>
</aside>
);
};
export default Sidebar;

View File

@@ -0,0 +1,43 @@
import { Search, Settings, Bell } from 'lucide-react';
import { Link } from 'react-router-dom';
const TopNav = () => {
return (
<header className="h-14 border-b border-border bg-background flex items-center justify-between px-6">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-full border border-border">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs font-medium text-foreground">Neon Prague: The Signal</span>
<span className="text-xs text-muted-foreground border-l border-border pl-2 ml-2">Google Flow (Veo 3.1)</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" size={16} />
<input
type="text"
placeholder="Search shots, script, or JSON..."
className="h-9 w-64 bg-muted/50 border border-border rounded-full pl-9 pr-4 text-sm focus:outline-none focus:ring-1 focus:ring-primary text-foreground placeholder:text-muted-foreground"
/>
</div>
<button className="text-muted-foreground hover:text-foreground transition-colors relative">
<Bell size={20} />
<span className="absolute top-0 right-0 w-2 h-2 bg-primary rounded-full" />
</button>
<Link to="/settings" className="text-muted-foreground hover:text-foreground transition-colors">
<Settings size={20} />
</Link>
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-xs font-bold text-white">
PS
</div>
</div>
</header>
);
};
export default TopNav;

View File

@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../lib/api';
import Modal from '../ui/Modal';
import { Loader2 } from 'lucide-react';
interface CreateProjectModalProps {
isOpen: boolean;
onClose: () => void;
}
const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen, onClose }) => {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [resolution, setResolution] = useState('4K');
const [aspectRatio, setAspectRatio] = useState('16:9');
const [veoVersion, setVeoVersion] = useState('3.1');
const createMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/projects/', {
name,
resolution,
aspect_ratio: aspectRatio,
veo_version: veoVersion
});
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
onClose();
setName('');
},
onError: (err) => {
console.error(err);
alert("Failed to create project");
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name) return;
createMutation.mutate();
}
return (
<Modal isOpen={isOpen} onClose={onClose} title="Create New Project">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Project Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Neon City Chase"
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm focus:ring-1 focus:ring-primary outline-none"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Resolution</label>
<select
value={resolution}
onChange={(e) => setResolution(e.target.value)}
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none"
>
<option value="1080p">1080p HD</option>
<option value="4K">4K UHD</option>
<option value="8K">8K</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Aspect Ratio</label>
<select
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none"
>
<option value="16:9">16:9 (Widescreen)</option>
<option value="2.35:1">2.35:1 (Cinemascope)</option>
<option value="4:3">4:3 (Classic)</option>
<option value="9:16">9:16 (Vertical)</option>
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Google Flow Version</label>
<select
value={veoVersion}
onChange={(e) => setVeoVersion(e.target.value)}
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none"
>
<option value="3.1">Veo 3.1 (Latest)</option>
<option value="3.0">Veo 3.0</option>
<option value="2.0">Veo 2.0 (Legacy)</option>
</select>
</div>
<div className="pt-4 flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createMutation.isPending || !name}
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors flex items-center gap-2 disabled:opacity-50"
>
{createMutation.isPending && <Loader2 className="animate-spin" size={16} />}
Create Project
</button>
</div>
</form>
</Modal>
);
};
export default CreateProjectModal;

View File

@@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import { X } from 'lucide-react';
import { createPortal } from 'react-dom';
import { cn } from '../../lib/utils';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
className?: string;
}
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, className }) => {
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleEsc);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEsc);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200">
<div
className={cn(
"bg-card border border-border rounded-lg shadow-lg w-full max-w-md flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200",
className
)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<button
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded-md hover:bg-muted"
>
<X size={20} />
</button>
</div>
<div className="p-4 overflow-y-auto">
{children}
</div>
</div>
</div>,
document.body
);
};
export default Modal;

View File

@@ -1,68 +1,46 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
@tailwind base;
@tailwind components;
@tailwind utilities;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
@layer base {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
--background: 224 71% 4%;
--foreground: 210 40% 98%;
--card: 222 47% 11%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 11%;
--popover-foreground: 210 40% 98%;
--primary: 217 91% 60%;
--primary-foreground: 222 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,10 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,353 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../lib/api';
import Editor from '@monaco-editor/react';
import { Layers, Wand2, Video, ChevronRight, ChevronDown, Plus, X, Image as ImageIcon, Send, Sparkles } from 'lucide-react';
import { cn } from '../lib/utils';
import Modal from '../components/ui/Modal';
const AssemblyPage = () => {
const queryClient = useQueryClient();
const [selectedProject, setSelectedProject] = useState<string>('');
const [selectedShotId, setSelectedShotId] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState<string>('// Select a shot to view its Flow JSON...');
const [expandedScenes, setExpandedScenes] = useState<Record<string, boolean>>({});
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [assets, setAssets] = useState<any[]>([]);
// AI Chat State
const [chatInput, setChatInput] = useState('');
// Fetch Projects
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const res = await api.get('/projects/');
return res.data;
}
});
// Fetch Project Structure
const { data: projectDetails } = useQuery({
queryKey: ['project', selectedProject],
queryFn: async () => {
if (!selectedProject) return null;
const res = await api.get(`/projects/${selectedProject}/script`);
return res.data;
},
enabled: !!selectedProject
});
// Fetch Shot Details
const { data: shotDetails } = useQuery({
queryKey: ['shot', selectedShotId],
queryFn: async () => {
if (!selectedShotId) return null;
const res = await api.get(`/shots/${selectedShotId}`);
return res.data;
},
enabled: !!selectedShotId
});
// Fetch Project Assets
useQuery({
queryKey: ['assets', selectedProject],
queryFn: async () => {
if (!selectedProject) return [];
const res = await api.get('/assets/', { params: { project_id: selectedProject } });
setAssets(res.data);
return res.data;
},
enabled: !!selectedProject
});
React.useEffect(() => {
if (shotDetails?.veo_json_payload) {
setEditorContent(JSON.stringify(shotDetails.veo_json_payload, null, 2));
} else if (shotDetails) {
setEditorContent('// No Flow generated yet. Configure assets and click "Generate".');
}
}, [shotDetails]);
React.useEffect(() => {
if (projects && projects.length > 0 && !selectedProject) {
setSelectedProject(projects[0].id);
}
}, [projects, selectedProject]);
const generateMutation = useMutation({
mutationFn: async () => {
if (!selectedShotId) return;
const res = await api.post(`/shots/${selectedShotId}/generate-flow`);
return res.data;
},
onSuccess: (data) => {
setEditorContent(JSON.stringify(data, null, 2));
queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] });
},
onError: () => alert("Generation failed")
});
const refineMutation = useMutation({
mutationFn: async (feedback: string) => {
if (!selectedShotId) return;
const res = await api.post(`/shots/${selectedShotId}/refine-flow`, { feedback });
return res.data;
},
onSuccess: (data) => {
setEditorContent(JSON.stringify(data, null, 2));
queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] });
setChatInput('');
},
onError: () => alert("Refinement failed")
});
const assignAssetMutation = useMutation({
mutationFn: async (assetId: string) => {
if (!selectedShotId || !shotDetails) return;
const current = shotDetails.assigned_ingredients || [];
const newIngredients = [...current, assetId];
await api.patch(`/shots/${selectedShotId}`, { assigned_ingredients: newIngredients });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] });
setIsAssetPickerOpen(false);
}
});
const removeAssetMutation = useMutation({
mutationFn: async (indexToRemove: number) => {
if (!selectedShotId || !shotDetails) return;
const current = shotDetails.assigned_ingredients || [];
const newIngredients = current.filter((_: any, idx: number) => idx !== indexToRemove);
await api.patch(`/shots/${selectedShotId}`, { assigned_ingredients: newIngredients });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['shot', selectedShotId] });
}
});
const handleChatSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!chatInput.trim()) return;
refineMutation.mutate(chatInput);
};
const toggleScene = (id: string) => {
setExpandedScenes(prev => ({ ...prev, [id]: !prev[id] }));
};
const getAssetUrl = (id: string) => {
const asset = assets.find(a => a.id === id);
return asset?.presigned_url;
};
return (
<div className="h-[calc(100vh-4rem)] flex flex-col">
{/* Toolbar */}
<div className="h-14 border-b border-border flex items-center px-4 gap-4 bg-card shrink-0">
<div className="flex items-center gap-2">
<Layers size={18} className="text-primary" />
<span className="font-semibold">Storyboard Assembly</span>
</div>
<div className="h-6 w-px bg-border mx-2" />
<select
className="bg-background border border-border rounded px-2 py-1 text-sm"
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
>
{projects?.map((p: any) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Navigation Sidebar */}
<div className="w-64 border-r border-border bg-muted/10 overflow-auto shrink-0">
{projectDetails?.scenes?.map((scene: any) => (
<div key={scene.id}>
<div
className="flex items-center gap-2 p-2 px-3 hover:bg-muted/50 cursor-pointer border-b border-border/50"
onClick={() => toggleScene(scene.id)}
>
{expandedScenes[scene.id] ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="font-mono text-[10px] font-bold text-muted-foreground truncate w-full">{scene.slugline}</span>
</div>
{expandedScenes[scene.id] && (
<div className="bg-background">
{scene.shots?.map((shot: any) => (
<div
key={shot.id}
className={cn(
"p-2 border-b border-border/50 cursor-pointer hover:bg-primary/5 transition-colors pl-6",
selectedShotId === shot.id && "bg-primary/10 border-l-2 border-l-primary"
)}
onClick={() => setSelectedShotId(shot.id)}
>
<div className="flex justify-between items-start mb-1">
<span className="text-[10px] font-mono font-bold bg-muted px-1 rounded text-foreground">
Shot {shot.sequence_number}
</span>
{shot.status === 'ready' && <Video size={10} className="text-green-500" />}
</div>
<p className="text-[10px] text-muted-foreground line-clamp-2">{shot.description}</p>
</div>
))}
</div>
)}
</div>
))}
</div>
{/* Main Content */}
<div className="flex-1 flex min-w-0">
{/* Visuals */}
<div className="flex-1 bg-background p-6 overflow-y-auto border-r border-border">
{selectedShotId && shotDetails ? (
<div className="space-y-6 max-w-2xl mx-auto">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="bg-primary/20 text-primary px-2 py-0.5 rounded text-xs font-mono font-bold">
SHOT {shotDetails.sequence_number}
</span>
<h2 className="text-lg font-semibold">Visual Description</h2>
</div>
<p className="text-foreground">{shotDetails.description}</p>
{shotDetails.llm_context_cache && (
<div className="text-sm text-muted-foreground bg-muted/30 p-3 rounded">
{shotDetails.llm_context_cache}
</div>
)}
</div>
<div>
<h3 className="text-sm font-bold uppercase text-muted-foreground mb-3 flex items-center gap-2">
<ImageIcon size={14} />
Visual Ingredients
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{shotDetails.assigned_ingredients?.map((assetId: string, idx: number) => (
<div key={idx} className="relative aspect-video bg-card border border-border rounded-lg overflow-hidden group">
{getAssetUrl(assetId) ? (
<img src={getAssetUrl(assetId)} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-xs text-muted-foreground">Loading...</div>
)}
<button
onClick={() => removeAssetMutation.mutate(idx)}
className="absolute top-1 right-1 bg-black/60 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={12} />
</button>
</div>
))}
<button
onClick={() => setIsAssetPickerOpen(true)}
className="aspect-video border-2 border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
<Plus size={20} />
<span className="text-xs font-medium">Add Asset</span>
</button>
</div>
</div>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
<Layers size={48} className="mb-4 opacity-50" />
<p>Select a shot to begin storyboarding.</p>
</div>
)}
</div>
{/* Editor & Chat Panel */}
<div className="w-96 flex flex-col bg-muted/5 border-l border-border">
<div className="h-10 border-b border-border flex items-center justify-between px-4 bg-muted/20 shrink-0">
<span className="text-xs font-bold text-muted-foreground">FLOW JSON</span>
<div className="flex gap-2">
<button
onClick={() => generateMutation.mutate()}
disabled={!selectedShotId || generateMutation.isPending}
className="flex items-center gap-1.5 px-2 py-1 bg-purple-600 text-white text-[10px] font-bold rounded hover:bg-purple-700 disabled:opacity-50"
>
{generateMutation.isPending ? <Wand2 className="animate-spin" size={12} /> : <Wand2 size={12} />}
GENERATE
</button>
</div>
</div>
{/* Editor */}
<div className="flex-1 min-h-0">
<Editor
height="100%"
defaultLanguage="json"
value={editorContent}
options={{
minimap: { enabled: false },
theme: "vs-dark",
fontSize: 12,
readOnly: true,
wordWrap: 'on'
}}
/>
</div>
{/* Chat / Refine Input */}
<div className="p-3 border-t border-border bg-card">
<div className="flex items-center gap-2 mb-2">
<Sparkles size={14} className="text-purple-400" />
<span className="text-xs font-bold text-muted-foreground">AI REFINEMENT</span>
</div>
<form onSubmit={handleChatSubmit} className="relative">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="e.g. 'Make it more dark and rainy'..."
className="w-full bg-muted/30 border border-border rounded-md pl-3 pr-8 py-2 text-xs focus:ring-1 focus:ring-primary outline-none"
disabled={!shotDetails?.veo_json_payload || refineMutation.isPending}
/>
<button
type="submit"
disabled={!shotDetails?.veo_json_payload || refineMutation.isPending}
className="absolute right-2 top-1.5 text-muted-foreground hover:text-primary transition-colors disabled:opacity-50"
>
{refineMutation.isPending ? <Wand2 className="animate-spin" size={14} /> : <Send size={14} />}
</button>
</form>
</div>
</div>
</div>
</div>
{/* Asset Picker Modal */}
<Modal
isOpen={isAssetPickerOpen}
onClose={() => setIsAssetPickerOpen(false)}
title="Select Asset"
className="max-w-2xl"
>
<div className="grid grid-cols-4 gap-4 p-2">
{assets.map((asset: any) => (
<div
key={asset.id}
onClick={() => assignAssetMutation.mutate(asset.id)}
className="aspect-square bg-muted rounded overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary relative"
>
<img src={asset.presigned_url} className="w-full h-full object-cover" />
<div className="absolute bottom-0 w-full bg-black/60 text-white text-[9px] px-1 py-0.5 truncate">
{asset.name}
</div>
</div>
))}
{assets.length === 0 && <div className="col-span-4 text-center py-4 text-muted-foreground text-sm">No assets in project.</div>}
</div>
</Modal>
</div>
);
};
export default AssemblyPage;

View File

@@ -0,0 +1,250 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../lib/api';
import { Upload, Loader2, Database, Trash2, Filter } from 'lucide-react';
import { cn } from '../lib/utils';
const AssetsPage = () => {
const queryClient = useQueryClient();
const [selectedProject, setSelectedProject] = useState<string>('');
const [filterType, setFilterType] = useState<string>('all');
const [uploadType, setUploadType] = useState<string>('Character');
// Fetch Projects
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const res = await api.get('/projects/');
return res.data;
}
});
// Default select first project
React.useEffect(() => {
if (projects && projects.length > 0 && !selectedProject) {
setSelectedProject(projects[0].id);
}
}, [projects, selectedProject]);
// Fetch Assets
const { data: assets, isLoading } = useQuery({
queryKey: ['assets', selectedProject, filterType],
queryFn: async () => {
if (!selectedProject) return [];
const params: any = { project_id: selectedProject };
if (filterType !== 'all') params.type = filterType;
const res = await api.get('/assets/', { params });
return res.data;
},
enabled: !!selectedProject
});
const uploadMutation = useMutation({
mutationFn: async (files: FileList) => {
const promises = Array.from(files).map(file => {
const formData = new FormData();
formData.append('project_id', selectedProject);
formData.append('type', uploadType);
formData.append('file', file);
return api.post('/assets/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
});
return Promise.all(promises);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assets'] });
alert("Upload Successful!");
},
onError: (err) => {
console.error("Upload error:", err);
alert("Upload Failed. Check console for details. Ensure Backend is running.");
}
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/assets/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assets'] });
}
});
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.files) {
uploadMutation.mutate(e.dataTransfer.files);
}
};
if (!projects || projects.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-96 text-muted-foreground">
<Database size={48} className="mb-4 opacity-50" />
<p>No projects found. Please create a project in the Dashboard first.</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Database className="text-primary" />
Asset Library
</h1>
<p className="text-muted-foreground text-sm">Manage ingredients (characters, locations, props) for your flow.</p>
</div>
<div className="flex items-center gap-2">
<select
className="bg-background border border-border rounded-md px-3 py-2 text-sm"
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
>
{projects?.map((p: any) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
</div>
{/* Upload Zone */}
<div
className={cn(
"border-2 border-dashed border-border rounded-lg p-8 text-center transition-colors bg-card",
uploadMutation.isPending ? "opacity-50" : "hover:border-primary/50"
)}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<div className="flex flex-col items-center gap-4 max-w-md mx-auto">
<div className="flex items-center gap-2 bg-muted p-1 rounded-lg">
<button
onClick={() => setUploadType('Character')}
className={cn("px-3 py-1 text-xs font-medium rounded-md transition-all", uploadType === 'Character' ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
>
Character
</button>
<button
onClick={() => setUploadType('Location')}
className={cn("px-3 py-1 text-xs font-medium rounded-md transition-all", uploadType === 'Location' ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
>
Location
</button>
<button
onClick={() => setUploadType('Object')}
className={cn("px-3 py-1 text-xs font-medium rounded-md transition-all", uploadType === 'Object' ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
>
Prop
</button>
</div>
<div className="flex flex-col items-center gap-2">
<div className="w-12 h-12 bg-primary/10 text-primary rounded-full flex items-center justify-center">
{uploadMutation.isPending ? <Loader2 className="animate-spin" /> : <Upload />}
</div>
<div className="text-sm font-medium">
Drag & Drop {uploadType} images here
</div>
<div className="text-xs text-muted-foreground">
or click to browse
</div>
</div>
<input
type="file"
multiple
accept="image/*,video/*"
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
onChange={(e) => e.target.files && uploadMutation.mutate(e.target.files)}
disabled={uploadMutation.isPending || !selectedProject}
/>
{!selectedProject && (
<p className="text-red-500 text-xs mt-2 font-medium animate-pulse">
Please select a project above to upload assets.
</p>
)}
</div>
</div>
{/* Filter Bar */}
<div className="flex items-center gap-2 text-sm border-b border-border pb-2">
<Filter size={16} className="text-muted-foreground mr-2" />
<button
onClick={() => setFilterType('all')}
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-colors", filterType === 'all' ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground")}
>
All Assets
</button>
<button
onClick={() => setFilterType('Character')}
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-colors", filterType === 'Character' ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground")}
>
Characters
</button>
<button
onClick={() => setFilterType('Location')}
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-colors", filterType === 'Location' ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground")}
>
Locations
</button>
<button
onClick={() => setFilterType('Object')}
className={cn("px-3 py-1 rounded-full text-xs font-medium transition-colors", filterType === 'Object' ? "bg-primary text-primary-foreground" : "hover:bg-muted text-muted-foreground")}
>
Props
</button>
</div>
{/* Grid */}
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="aspect-square bg-muted/20 animate-pulse rounded-lg" />
))}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{assets?.map((asset: any) => (
<div key={asset.id} className="group relative aspect-square bg-card border border-border rounded-lg overflow-hidden hover:border-primary transition-colors">
<img
src={asset.presigned_url}
alt={asset.name}
className="w-full h-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
<p className="text-xs font-medium text-white truncate">{asset.name}</p>
<div className="flex justify-between items-center mt-1">
<span className="text-[10px] uppercase tracking-wider text-gray-300 bg-white/10 px-1.5 rounded">{asset.type}</span>
<button
onClick={(e) => {
e.preventDefault();
if (confirm('Delete asset?')) deleteMutation.mutate(asset.id);
}}
className="text-red-400 hover:text-red-300 bg-black/50 p-1 rounded"
>
<Trash2 size={12} />
</button>
</div>
</div>
</div>
))}
</div>
)}
{!isLoading && assets?.length === 0 && (
<div className="text-center py-12 text-muted-foreground text-sm italic">
No assets found. Upload some above.
</div>
)}
</div>
);
};
export default AssetsPage;

View File

@@ -0,0 +1,131 @@
import { useState } from 'react';
import { Layers, Database, ArrowUpRight, CheckCircle2, Plus } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import api from '../lib/api';
import CreateProjectModal from '../components/projects/CreateProjectModal';
const StatCard = ({ title, value, subtext, icon: Icon, color }: any) => (
<div className="bg-card border border-border p-5 rounded-lg">
<div className="flex justify-between items-start mb-2">
<h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider">{title}</h3>
<Icon size={18} className={color} />
</div>
<div className="text-3xl font-light text-foreground mb-1">
{value}
</div>
<div className="text-xs text-muted-foreground">
{subtext}
</div>
</div>
);
const Dashboard = () => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
// Fetch Projects
const { data: projects, isLoading } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const res = await api.get('/projects/');
return res.data;
}
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<button
onClick={() => setIsCreateModalOpen(true)}
className="bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded-md font-medium flex items-center gap-2 text-sm transition-colors"
>
<Plus size={16} />
New Project
</button>
</div>
<CreateProjectModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
{/* Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Active Projects"
value={isLoading ? "..." : projects?.length || 0}
subtext="Total productions"
icon={Layers}
color="text-primary"
/>
<StatCard
title="Total Assets"
value="86"
subtext="Across all projects"
icon={Database}
color="text-blue-500"
/>
<StatCard
title="Pipelines Running"
value="3"
subtext="Flow generation jobs"
icon={CheckCircle2}
color="text-green-500"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Project List */}
<div className="lg:col-span-3 bg-card border border-border rounded-lg p-6">
<div className="flex items-center gap-2 mb-6">
<Layers className="text-primary" size={20} />
<h2 className="text-lg font-semibold">Active Projects</h2>
</div>
<div className="space-y-3">
{isLoading && <div className="text-muted-foreground text-sm">Loading projects...</div>}
{!isLoading && projects?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No projects found. Create one to get started.
</div>
)}
{projects?.map((project: any) => (
<div key={project.id} className="flex items-center justify-between p-3 bg-muted/10 hover:bg-muted/20 rounded border border-border/50 transition-colors group">
<div className="flex items-center gap-4">
<div className="h-8 w-8 rounded bg-primary/20 flex items-center justify-center text-primary font-bold text-xs">
{project.name.substring(0, 2).toUpperCase()}
</div>
<div>
<div className="font-semibold text-sm text-foreground">{project.name}</div>
<div className="text-xs text-muted-foreground">{project.resolution} {project.aspect_ratio} Veo {project.veo_version}</div>
</div>
</div>
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Link to="/script" className="text-xs bg-secondary hover:bg-secondary/80 text-secondary-foreground px-2 py-1 rounded">
Script
</Link>
<Link to="/assembly" className="text-xs bg-secondary hover:bg-secondary/80 text-secondary-foreground px-2 py-1 rounded">
Assembly
</Link>
</div>
</div>
))}
</div>
</div>
{/* Action Card */}
<div className="bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/20 rounded-lg p-6 flex flex-col justify-center items-center text-center h-fit">
<Link to="/assets" className="w-full">
<button className="w-full bg-primary hover:bg-primary/90 text-white py-3 rounded-md font-medium flex items-center justify-center gap-2 mb-4 transition-colors shadow-lg shadow-primary/20">
<ArrowUpRight size={18} />
Import Assets
</button>
</Link>
<p className="text-xs text-muted-foreground leading-relaxed px-2">
Central asset repository.
</p>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,224 @@
import React, { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import api from '../lib/api';
import { Upload, FileText, Loader2, ChevronDown, ChevronRight, Video, Clapperboard } from 'lucide-react';
import { cn } from '../lib/utils';
// Types matching backend response
interface Shot {
shot_number: string;
description: string;
visual_notes?: string;
dialogue?: string;
}
interface Scene {
scene_number: string;
heading: string;
description: string;
shots: Shot[];
}
interface ScriptResponse {
scenes: Scene[];
}
const ScriptPage = () => {
const [parsedScript, setParsedScript] = useState<ScriptResponse | null>(null);
const [expandedScenes, setExpandedScenes] = useState<Record<string, boolean>>({});
const [selectedProject, setSelectedProject] = useState<string>('');
// Fetch Projects
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: async () => {
const res = await api.get('/projects/');
return res.data;
}
});
const parseMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const res = await api.post('/scripts/parse', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return res.data as ScriptResponse;
},
onSuccess: (data) => {
setParsedScript(data);
if (data.scenes.length > 0) {
toggleScene(data.scenes[0].scene_number);
}
},
onError: (err) => {
console.error(err);
alert("Failed to parse script");
}
});
const importMutation = useMutation({
mutationFn: async () => {
if (!selectedProject || !parsedScript) return;
return await api.post(`/projects/${selectedProject}/import-script`, parsedScript);
},
onSuccess: () => {
alert("Script imported successfully!");
},
onError: () => {
alert("Failed to import script");
}
});
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
parseMutation.mutate(e.target.files[0]);
}
};
const toggleScene = (sceneNum: string) => {
setExpandedScenes(prev => ({
...prev,
[sceneNum]: !prev[sceneNum]
}));
};
// Auto-select first project
React.useEffect(() => {
if (projects && projects.length > 0 && !selectedProject) {
setSelectedProject(projects[0].id);
}
}, [projects, selectedProject]);
return (
<div className="space-y-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<FileText className="text-primary" />
Master Script
</h1>
{parsedScript && (
<div className="flex items-center gap-2">
<select
className="p-2 border border-border bg-background rounded text-foreground text-sm"
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
>
<option value="" disabled>Select Project to Save</option>
{projects?.map((p: any) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<button
onClick={() => importMutation.mutate()}
disabled={!selectedProject || importMutation.isPending}
className={cn(
"px-4 py-2 bg-green-600 text-white rounded text-sm font-medium hover:bg-green-700 transition-colors flex items-center gap-2",
(!selectedProject || importMutation.isPending) && "opacity-50 cursor-not-allowed"
)}
>
{importMutation.isPending && <Loader2 className="animate-spin" size={16} />}
Save to Project
</button>
</div>
)}
</div>
{/* Upload Area */}
{!parsedScript && (
<div className="bg-card border border-border border-dashed rounded-lg p-12 text-center h-64 flex flex-col items-center justify-center gap-4">
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center">
{parseMutation.isPending ? (
<Loader2 className="animate-spin text-muted-foreground" size={32} />
) : (
<Upload className="text-muted-foreground" size={32} />
)}
</div>
<div className="space-y-1">
<h3 className="font-semibold text-lg">Upload Screenplay</h3>
<p className="text-sm text-muted-foreground">Supported formats: .txt, .md (Fountain-like)</p>
</div>
<label className={cn(
"px-6 py-2 bg-primary text-primary-foreground rounded-md font-medium cursor-pointer hover:bg-primary/90 transition-colors mt-2",
parseMutation.isPending && "opacity-50 cursor-not-allowed pointer-events-none"
)}>
{parseMutation.isPending ? "Analyzing..." : "Select File"}
<input type="file" className="hidden" accept=".txt,.md" onChange={handleFileUpload} disabled={parseMutation.isPending} />
</label>
</div>
)}
{/* Results */}
{parsedScript && (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">{parsedScript.scenes.length} Scenes Identified</h2>
<button
onClick={() => setParsedScript(null)}
className="text-sm text-muted-foreground hover:text-foreground"
>
Upload New Script
</button>
</div>
{parsedScript.scenes.map((scene) => (
<div key={scene.scene_number} className="bg-card border border-border rounded-lg overflow-hidden">
<div
className="flex items-center gap-4 p-4 bg-muted/20 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => toggleScene(scene.scene_number)}
>
{expandedScenes[scene.scene_number] ? (
<ChevronDown size={20} className="text-muted-foreground" />
) : (
<ChevronRight size={20} className="text-muted-foreground" />
)}
<div className="font-mono text-sm font-bold bg-background border border-border px-2 py-1 rounded min-w-[3rem] text-center">
{scene.scene_number}
</div>
<div className="flex-1 font-bold text-foreground">{scene.heading}</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Clapperboard size={14} />
{scene.shots.length} Shots
</div>
</div>
{expandedScenes[scene.scene_number] && (
<div className="p-4 border-t border-border space-y-4 bg-background/50">
<p className="text-sm text-muted-foreground italic mb-4">{scene.description}</p>
<div className="grid gap-4 pl-4 border-l-2 border-border/50">
{scene.shots.map((shot) => (
<div key={shot.shot_number} className="bg-card border border-border rounded p-3 text-sm">
<div className="flex items-center gap-2 mb-2">
<span className="bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs font-mono font-bold">
{shot.shot_number}
</span>
{shot.visual_notes && (
<span className="text-xs text-muted-foreground border border-border px-1.5 py-0.5 rounded flex items-center gap-1">
<Video size={10} />
{shot.visual_notes}
</span>
)}
</div>
<p className="text-foreground mb-2">{shot.description}</p>
{shot.dialogue && (
<div className="bg-muted/30 p-2 rounded text-xs font-mono text-muted-foreground border border-border/50 whitespace-pre-wrap">
{shot.dialogue}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default ScriptPage;

View File

@@ -0,0 +1,116 @@
import { useState } from 'react';
import { Save, Server, Cpu, Database } from 'lucide-react';
const SettingsPage = () => {
// In a real app we would fetch this from backend/localstorage.
// Mocking for MVP UI completeness.
const [settings, setSettings] = useState({
aiModel: 'gemini-2.0-flash-exp',
apiBase: 'https://generativelanguage.googleapis.com/v1beta/openai/',
theme: 'dark',
autoSave: true,
debugMode: false
});
const handleChange = (key: string, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const handleSave = () => {
// Here we would implement a backend call to save user profile/settings
// For now, simple alert
alert("Settings saved (Local Session Only for MVP)");
};
return (
<div className="max-w-4xl mx-auto space-y-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="text-primary" />
System Configuration
</h1>
<div className="bg-card border border-border rounded-lg p-6 space-y-8">
{/* AI Configuration */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Cpu size={18} />
AI Provider Settings
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium">Model Name</label>
<select
value={settings.aiModel}
onChange={(e) => handleChange('aiModel', e.target.value)}
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none"
>
<option value="gemini-2.0-flash-exp">Gemini 2.0 Flash (Experimental)</option>
<option value="gpt-4o">GPT-4o</option>
<option value="claude-3-5-sonnet">Claude 3.5 Sonnet</option>
</select>
<p className="text-xs text-muted-foreground">Select the LLM used for Script Parsing and Flow Generation.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">API Base URL</label>
<input
type="text"
value={settings.apiBase}
onChange={(e) => handleChange('apiBase', e.target.value)}
className="w-full bg-background border border-border rounded-md px-3 py-2 text-sm outline-none font-mono"
/>
</div>
</div>
</section>
<hr className="border-border" />
{/* System Settings */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Database size={18} />
Application Preferences
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Auto-Save Script Changes</div>
<div className="text-xs text-muted-foreground">Automatically save changes to the database when parsing/editing.</div>
</div>
<input
type="checkbox"
checked={settings.autoSave}
onChange={(e) => handleChange('autoSave', e.target.checked)}
className="toggle"
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Debug Mode</div>
<div className="text-xs text-muted-foreground">Show detailed LLM logs and context in the UI.</div>
</div>
<input
type="checkbox"
checked={settings.debugMode}
onChange={(e) => handleChange('debugMode', e.target.checked)}
className="toggle"
/>
</div>
</div>
</section>
<div className="pt-4 flex justify-end">
<button
onClick={handleSave}
className="bg-primary hover:bg-primary/90 text-primary-foreground px-6 py-2 rounded-md font-medium flex items-center gap-2 transition-colors"
>
<Save size={18} />
Save Configuration
</button>
</div>
</div>
</div>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [],
}

View File

@@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})