no idea
This commit is contained in:
@@ -54,6 +54,10 @@ export function App() {
|
||||
<Route path="/workspaces" element={<Dashboard.WorkspaceListScreen />} />
|
||||
<Route path="/workspaces/new" element={<Dashboard.NewWorkspaceRedirect />} />
|
||||
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||
<Route path="/projects" element={<Dashboard.ProjectListScreen />} />
|
||||
<Route path="/projects/new" element={<Dashboard.NewProjectRedirect />} />
|
||||
<Route path="/projects/:id" element={<Dashboard.ProjectScreen />} />
|
||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -12,15 +12,16 @@ const initialLayout: LayoutNode = {
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'home-right',
|
||||
id: 'home-center',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'homepage-widget-panel', appType: 'widget-panel' }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'home-chat', appType: 'chat-launcher' }, size: 40 },
|
||||
],
|
||||
},
|
||||
size: 80,
|
||||
size: 60,
|
||||
},
|
||||
{ node: { type: 'panel', id: 'home-right', appType: 'project-list' }, size: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { Background } from './Background';
|
||||
import { Header } from './Header';
|
||||
import { Dock, dockItems } from './Dock';
|
||||
@@ -6,12 +8,18 @@ type DashboardLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const { user } = useAuth();
|
||||
const visibleItems = useMemo(
|
||||
() => dockItems.filter((item) => !item.role || item.role === user?.role),
|
||||
[user?.role],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||
<Header />
|
||||
<section className="relative h-dvh snap-start overflow-hidden">
|
||||
<Background />
|
||||
<Dock items={dockItems} />
|
||||
<Dock items={visibleItems} />
|
||||
<div className="absolute inset-0 z-2 pt-[52px] md:pt-[64px] pb-2 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type DockItem = {
|
||||
to: string;
|
||||
icon: LucideIcon;
|
||||
color: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type DockProps = {
|
||||
@@ -109,7 +110,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
};
|
||||
|
||||
|
||||
import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText } from 'lucide-react';
|
||||
import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react';
|
||||
|
||||
export const dockItems: DockItem[] = [
|
||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
@@ -119,5 +120,7 @@ export const dockItems: DockItem[] = [
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' },
|
||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||
{ label: 'Terminal', to: '/terminal', icon: Monitor, color: '#f97316', role: 'Super Admin' },
|
||||
{ label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' },
|
||||
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { FolderKanban, Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { generateSlug } from 'helpers/slug';
|
||||
import { useProjectsState } from '@/state/useProjectsState';
|
||||
import type { ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
SELECTED_PROJECT,
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
} from './constants';
|
||||
|
||||
const PROJECT_TYPE_LABELS: Record<ProjectType, string> = {
|
||||
'landing-page': 'Landing Page',
|
||||
'website': 'Website',
|
||||
'app': 'App',
|
||||
};
|
||||
|
||||
export const ProjectListApp = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [projects, setProjects] = useProjectsState<ProjectDefinition[]>('projects', []);
|
||||
const [selected, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleting, setDeleting] = useState<ProjectDefinition | null>(null);
|
||||
const isProjectsPage = location.pathname === '/projects';
|
||||
const filtered = search
|
||||
? projects.filter((p) => {
|
||||
const q = search.toLowerCase();
|
||||
return [p.name, p.id, p.description ?? '', p.cwd ?? '', p.projectType].some((field) =>
|
||||
field.toLowerCase().includes(q),
|
||||
);
|
||||
})
|
||||
: projects;
|
||||
|
||||
const handleEdit = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||
ev.stopPropagation();
|
||||
setSelected(null);
|
||||
setCreating(false);
|
||||
setEditing(p.id);
|
||||
setName(p.name);
|
||||
setDescription(p.description ?? '');
|
||||
setTemplateIdx(p.templateIdx ?? 0);
|
||||
setProjectType(p.projectType);
|
||||
setHasBackend(p.hasBackend ?? false);
|
||||
setHasAuth(p.hasAuth ?? false);
|
||||
};
|
||||
|
||||
const handleDelete = (ev: React.MouseEvent, p: ProjectDefinition) => {
|
||||
ev.stopPropagation();
|
||||
setDeleting(p);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleting) return;
|
||||
setProjects((prev) => prev.filter((p) => p.id !== deleting.id));
|
||||
if (selected === deleting.id) setSelected(null);
|
||||
|
||||
const layoutKey = `proj-layout-${deleting.id}`;
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['PROJECTS_STATE']) ?? {};
|
||||
const { [layoutKey]: _, ...rest } = currentState;
|
||||
queryClient.setQueryData(['PROJECTS_STATE'], rest);
|
||||
client.patch('/user/projects-state', { [layoutKey]: null }).catch(() => {});
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
const handleClick = (p: ProjectDefinition) => {
|
||||
if (isProjectsPage) {
|
||||
setSelected(p.id);
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
} else {
|
||||
navigate(`/projects/${p.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-0 flex flex-col gap-2">
|
||||
<Link
|
||||
to="/projects"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400"
|
||||
>
|
||||
<FolderKanban className="h-4 w-4" />
|
||||
Projects
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setProjectType('app');
|
||||
setHasBackend(false);
|
||||
setHasAuth(false);
|
||||
setCreating(true);
|
||||
if (!isProjectsPage) navigate('/projects');
|
||||
}}
|
||||
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-500/90 text-white transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative px-3 pt-2">
|
||||
<Search className="absolute left-6 top-1/2 h-3.5 w-3.5 text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(ev) => setSearch(ev.target.value)}
|
||||
placeholder="Search projects"
|
||||
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-emerald-500/30 focus:border-emerald-500/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 pt-2">
|
||||
{filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
|
||||
isProjectsPage && selected === p.id
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-white/80 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
onClick={() => handleClick(p)}
|
||||
>
|
||||
<FolderKanban className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{p.name}</span>
|
||||
<span className="text-[10px] text-duck-dark/30 shrink-0">{PROJECT_TYPE_LABELS[p.projectType]}</span>
|
||||
{isProjectsPage && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleEdit(ev, p)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-emerald-400 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleDelete(ev, p)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-gray-500 px-3 py-4 text-center">
|
||||
{search ? 'No matches' : 'No projects yet'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete project</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,647 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { FolderKanban, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useProjectsState } from '@/state/useProjectsState';
|
||||
import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, ProjectDefinition, ProjectType } from '@/components/Workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateSlug, slugify } from 'helpers/slug';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import {
|
||||
SELECTED_PROJECT,
|
||||
CREATING_PROJECT,
|
||||
EDITING_PROJECT,
|
||||
NEW_PROJ_NAME,
|
||||
NEW_PROJ_DESC,
|
||||
NEW_PROJ_TEMPLATE,
|
||||
NEW_PROJ_TYPE,
|
||||
NEW_PROJ_HAS_BACKEND,
|
||||
NEW_PROJ_HAS_AUTH,
|
||||
NEW_PROJ_PREVIEW_LAYOUT,
|
||||
} from './constants';
|
||||
import { ProjectListApp } from './ProjectListApp';
|
||||
|
||||
const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'proj-home-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'proj-home-list', appType: 'project-list' }, size: 25 },
|
||||
{ node: { type: 'panel', id: 'proj-home-right', appType: 'project-preview' }, size: 75 },
|
||||
],
|
||||
};
|
||||
|
||||
// --- Layout Templates ---
|
||||
|
||||
let tplCounter = 0;
|
||||
const tplUid = () => `ptpl-${++tplCounter}`;
|
||||
|
||||
type LayoutTemplate = {
|
||||
name: string;
|
||||
layout: () => LayoutNode;
|
||||
};
|
||||
|
||||
const templates: LayoutTemplate[] = [
|
||||
{
|
||||
name: 'Single',
|
||||
layout: () => ({ type: 'panel', id: tplUid(), appType: null }),
|
||||
},
|
||||
{
|
||||
name: '2 Columns',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Main + Side',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Sidebar',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 25 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 75 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: '2x2 Grid',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Cols + Bottom',
|
||||
layout: () => ({
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: tplUid(),
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 70,
|
||||
},
|
||||
{ node: { type: 'panel', id: tplUid(), appType: null }, size: 30 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
// --- Template Thumbnails ---
|
||||
|
||||
const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
|
||||
if (node.type === 'panel') {
|
||||
return <div className="h-full w-full bg-emerald-500/15 border border-emerald-500/20 rounded-sm" />;
|
||||
}
|
||||
const isH = node.direction === 'horizontal';
|
||||
return (
|
||||
<div className={`flex h-full w-full gap-1.5 ${isH ? 'flex-row' : 'flex-col'}`}>
|
||||
{node.children.map((child) => (
|
||||
<div key={child.node.id} style={{ flex: child.size }} className="min-h-0 min-w-0">
|
||||
<ThumbnailNode node={child.node} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateCell = ({ index }: { index: number }) => {
|
||||
const tpl = templates[index]!;
|
||||
const [selected, setSelected] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const node = tpl.layout();
|
||||
const isSelected = selected === index;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(index)}
|
||||
className={`flex h-full w-full flex-col items-center justify-center gap-2 p-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-emerald-500/10' : 'hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
|
||||
<ThumbnailNode node={node} />
|
||||
</div>
|
||||
<span className={`text-xs font-medium ${isSelected ? 'text-emerald-400' : 'text-duck-dark/60'}`}>{tpl.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Template Picker (3x2 workspace) ---
|
||||
|
||||
const tplPanelLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'ptpl-root',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'ptpl-row-0',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'ptpl-0', appType: 'ptpl-0' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-1', appType: 'ptpl-1' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-2', appType: 'ptpl-2' }, size: 33.34 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'ptpl-row-1',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'ptpl-3', appType: 'ptpl-3' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-4', appType: 'ptpl-4' }, size: 33.33 },
|
||||
{ node: { type: 'panel', id: 'ptpl-5', appType: 'ptpl-5' }, size: 33.34 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const tplRegistry = Object.fromEntries(
|
||||
templates.map((tpl, i) => [`ptpl-${i}`, { name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }]),
|
||||
);
|
||||
|
||||
const TemplatePanel = () => (
|
||||
<WorkspaceLayout layout={tplPanelLayout} onLayoutChange={() => {}} registry={tplRegistry} />
|
||||
);
|
||||
|
||||
// --- Panel: Details (Name + Description + Project Type + Backend/Auth toggles) ---
|
||||
|
||||
const PROJECT_TYPE_OPTIONS: { value: ProjectType; label: string }[] = [
|
||||
{ value: 'landing-page', label: 'Landing Page' },
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'app', label: 'App' },
|
||||
];
|
||||
|
||||
const DetailsPanel = () => {
|
||||
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [projectType, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [hasBackend, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [hasAuth, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||
<Type className="h-3 w-3" />
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
placeholder="My Project"
|
||||
autoFocus
|
||||
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
|
||||
<FileText className="h-3 w-3" />
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
placeholder="What is this project for?"
|
||||
rows={3}
|
||||
className="resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-duck-dark/50">Project Type</label>
|
||||
<div className="flex gap-2">
|
||||
{PROJECT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setProjectType(opt.value)}
|
||||
className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium border transition-colors cursor-pointer ${
|
||||
projectType === opt.value
|
||||
? 'bg-emerald-500/15 border-emerald-500/40 text-emerald-400'
|
||||
: 'border-duck-dark/20 text-duck-dark/50 hover:border-duck-dark/30'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{projectType === 'app' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasBackend}
|
||||
onChange={(ev) => setHasBackend(ev.target.checked)}
|
||||
className="accent-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/60">Has Backend</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hasAuth}
|
||||
onChange={(ev) => setHasAuth(ev.target.checked)}
|
||||
className="accent-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-duck-dark/60">Has Auth</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Create ---
|
||||
|
||||
const CreatePanel = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useClient();
|
||||
const [name, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [projects, setProjects] = useProjectsState<ProjectDefinition[]>('projects', []);
|
||||
|
||||
const [description, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [templateIdx, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const [previewLayout] = useGlobal<LayoutNode>(NEW_PROJ_PREVIEW_LAYOUT, createDefaultLayout());
|
||||
const [projectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [hasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [hasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
const [editingId, setEditingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
|
||||
const isEditing = !!editingId;
|
||||
const slug = slugify(name.trim()) || generateSlug();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const desc = description.trim();
|
||||
|
||||
if (isEditing) {
|
||||
const existingIds = new Set(projects.filter((p) => p.id !== editingId).map((p) => p.id));
|
||||
let newId = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(newId)) newId = `${newId}-${generateSlug(1)}`;
|
||||
|
||||
const idChanged = newId !== editingId;
|
||||
|
||||
setProjects((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === editingId
|
||||
? {
|
||||
...p,
|
||||
id: newId,
|
||||
name: trimmed,
|
||||
cwd: '',
|
||||
description: desc || undefined,
|
||||
projectType,
|
||||
hasBackend: projectType === 'app' ? hasBackend : undefined,
|
||||
hasAuth: projectType === 'app' ? hasAuth : undefined,
|
||||
templateIdx,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['PROJECTS_STATE']) ?? {};
|
||||
const newLayoutKey = `proj-layout-${newId}`;
|
||||
|
||||
if (idChanged) {
|
||||
const oldKeys = [`proj-layout-${editingId}`, `proj-terminals-${editingId}`, `proj-host-terminals-${editingId}`];
|
||||
const cleaned = { ...currentState };
|
||||
for (const k of oldKeys) delete cleaned[k];
|
||||
queryClient.setQueryData(['PROJECTS_STATE'], { ...cleaned, [newLayoutKey]: previewLayout });
|
||||
|
||||
const patch: Record<string, unknown> = { [newLayoutKey]: previewLayout };
|
||||
for (const k of oldKeys) patch[k] = null;
|
||||
client.patch('/user/projects-state', patch).catch(() => {});
|
||||
} else {
|
||||
queryClient.setQueryData(['PROJECTS_STATE'], { ...currentState, [newLayoutKey]: previewLayout });
|
||||
client.patch('/user/projects-state', { [newLayoutKey]: previewLayout }).catch(() => {});
|
||||
}
|
||||
|
||||
setEditingId(null);
|
||||
setSelected(newId);
|
||||
} else {
|
||||
const existingIds = new Set(projects.map((p) => p.id));
|
||||
let id = slugify(trimmed) || generateSlug();
|
||||
while (existingIds.has(id)) id = `${id}-${generateSlug(1)}`;
|
||||
|
||||
const proj: ProjectDefinition = {
|
||||
id,
|
||||
name: trimmed,
|
||||
cwd: '',
|
||||
description: desc || undefined,
|
||||
projectType,
|
||||
hasBackend: projectType === 'app' ? hasBackend : undefined,
|
||||
hasAuth: projectType === 'app' ? hasAuth : undefined,
|
||||
templateIdx,
|
||||
};
|
||||
const layoutKey = `proj-layout-${proj.id}`;
|
||||
|
||||
setProjects((prev) => [...prev, proj]);
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['PROJECTS_STATE']) ?? {};
|
||||
queryClient.setQueryData(['PROJECTS_STATE'], { ...currentState, [layoutKey]: previewLayout });
|
||||
client.patch('/user/projects-state', { [layoutKey]: previewLayout }).catch(() => {});
|
||||
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
navigate(`/projects/${proj.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-4">
|
||||
<Rocket className="h-8 w-8 text-emerald-500/40" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-duck-dark/70">{name.trim() || 'Untitled'}</p>
|
||||
<p className="text-xs font-mono text-duck-dark/40 mt-1">projects/{slug}</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim()}
|
||||
className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{isEditing ? 'Update Project' : 'Create Project'}
|
||||
</Button>
|
||||
{isEditing && (
|
||||
<Button
|
||||
onClick={() => navigate(`/projects/${editingId}`)}
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 mr-1" />
|
||||
Open Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Panel: Live Template Preview (full interactive sub-workspace) ---
|
||||
// Uses local state for WorkspaceView to avoid cascading re-renders,
|
||||
// and syncs to global query cache (write-only) so CreatePanel can read it.
|
||||
|
||||
const PREVIEW_LAYOUT_KEY = ['USE_GLOBAL', NEW_PROJ_PREVIEW_LAYOUT];
|
||||
|
||||
const TemplatePreviewInner = ({ templateIdx }: { templateIdx: number }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [layout, setLocalLayout] = useState<LayoutNode>(() => {
|
||||
const initial = templates[templateIdx]!.layout();
|
||||
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, initial);
|
||||
return initial;
|
||||
});
|
||||
|
||||
const handleLayoutChange = (next: LayoutNode) => {
|
||||
setLocalLayout(next);
|
||||
queryClient.setQueryData(PREVIEW_LAYOUT_KEY, next);
|
||||
};
|
||||
|
||||
return <WorkspaceView workspace={null} layout={layout} onLayoutChange={handleLayoutChange} registry={appRegistry} />;
|
||||
};
|
||||
|
||||
const TemplatePreviewPanel = () => {
|
||||
const [selectedIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
return <TemplatePreviewInner key={selectedIdx} templateIdx={selectedIdx} />;
|
||||
};
|
||||
|
||||
// --- New Project Layout ---
|
||||
|
||||
const newProjLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'new-proj-root',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'new-proj-top',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'new-proj-details', appType: 'new-proj-details' }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'new-proj-template', appType: 'new-proj-template' }, size: 70 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'new-proj-bottom',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'new-proj-placeholder', appType: 'new-proj-placeholder' }, size: 70 },
|
||||
{ node: { type: 'panel', id: 'new-proj-create', appType: 'new-proj-create' }, size: 30 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newProjRegistry = {
|
||||
...appRegistry,
|
||||
'new-proj-details': { name: 'Details', icon: Type, component: DetailsPanel },
|
||||
'new-proj-placeholder': { name: 'Preview', icon: Layout, component: TemplatePreviewPanel },
|
||||
'new-proj-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
||||
'new-proj-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||
};
|
||||
|
||||
const NewProjectForm = () => (
|
||||
<WorkspaceLayout layout={newProjLayout} onLayoutChange={() => {}} registry={newProjRegistry} />
|
||||
);
|
||||
|
||||
const ProjectPreviewEmpty = () => {
|
||||
const [creating, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [editingId] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
|
||||
if (creating || editingId) return <NewProjectForm />;
|
||||
|
||||
const handleCreate = () => {
|
||||
setName(generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setProjectType('app');
|
||||
setHasBackend(false);
|
||||
setHasAuth(false);
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<FolderKanban className="h-10 w-10 text-white/80" />
|
||||
<p className="text-sm text-white/80">Select a project or create a new one</p>
|
||||
<Button onClick={handleCreate} className="bg-emerald-500 hover:bg-emerald-500/90 cursor-pointer">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectPreviewInner = ({ project }: { project: ProjectDefinition }) => {
|
||||
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
|
||||
|
||||
return <WorkspaceView workspace={workspace} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />;
|
||||
};
|
||||
|
||||
const ProjectPreview = () => {
|
||||
const [selectedId] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [projects] = useProjectsState<ProjectDefinition[]>('projects', []);
|
||||
const project = selectedId ? projects.find((p) => p.id === selectedId) : null;
|
||||
|
||||
if (!project) return <ProjectPreviewEmpty />;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ProjectPreviewInner key={project.id} project={project} />
|
||||
<Link
|
||||
to={`/projects/${project.id}`}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-transparent hover:bg-duck-dark/10 transition-colors group"
|
||||
>
|
||||
<ArrowRight className="h-16 w-16 text-emerald-500/0 group-hover:text-emerald-500/60 transition-colors" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const projHomeRegistry = {
|
||||
...appRegistry,
|
||||
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp /> },
|
||||
'project-preview': { name: 'Project Preview', icon: FolderKanban, component: ProjectPreview },
|
||||
};
|
||||
|
||||
export const ProjectListScreen = () => {
|
||||
const [layout, setLayout, isLoaded] = useProjectsState<LayoutNode>('proj-layout-proj-homepage', defaultLayout);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} registry={projHomeRegistry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewProjectRedirect = () => {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const [, setName] = useGlobal<string>(NEW_PROJ_NAME, '');
|
||||
const [, setDescription] = useGlobal<string>(NEW_PROJ_DESC, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_PROJ_TEMPLATE, 0);
|
||||
const [, setCreating] = useGlobal<boolean>(CREATING_PROJECT, false);
|
||||
const [, setEditing] = useGlobal<string | null>(EDITING_PROJECT, null);
|
||||
const [, setSelected] = useGlobal<string | null>(SELECTED_PROJECT, null);
|
||||
const [, setProjectType] = useGlobal<ProjectType>(NEW_PROJ_TYPE, 'app');
|
||||
const [, setHasBackend] = useGlobal<boolean>(NEW_PROJ_HAS_BACKEND, false);
|
||||
const [, setHasAuth] = useGlobal<boolean>(NEW_PROJ_HAS_AUTH, false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setName(params.get('name') || generateSlug());
|
||||
setDescription('');
|
||||
setTemplateIdx(0);
|
||||
setProjectType('app');
|
||||
setHasBackend(false);
|
||||
setHasAuth(false);
|
||||
setCreating(true);
|
||||
navigate('/projects', { replace: true });
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useParams, Navigate } from 'react-router';
|
||||
import { useProjectsState } from '@/state/useProjectsState';
|
||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode, ProjectDefinition } from '@/components/Workspace';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
|
||||
export const ProjectScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [projects, , isLoaded] = useProjectsState<ProjectDefinition[]>('projects', []);
|
||||
const project = projects.find((p) => p.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
if (!project) return <Navigate to="/projects" replace />;
|
||||
|
||||
return <ProjectScreenInner project={project} />;
|
||||
};
|
||||
|
||||
const ProjectScreenInner = ({ project }: { project: ProjectDefinition }) => {
|
||||
const [layout, setLayout] = useProjectsState<LayoutNode>(`proj-layout-${project.id}`, createDefaultLayout());
|
||||
const workspace = { id: project.id, name: project.name, cwd: project.cwd };
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
layout={layout}
|
||||
onLayoutChange={setLayout}
|
||||
registry={appRegistry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export const SELECTED_PROJECT = 'SELECTED_PROJECT';
|
||||
export const CREATING_PROJECT = 'CREATING_PROJECT';
|
||||
export const EDITING_PROJECT = 'EDITING_PROJECT';
|
||||
export const NEW_PROJ_NAME = 'NEW_PROJ_NAME';
|
||||
export const NEW_PROJ_DESC = 'NEW_PROJ_DESC';
|
||||
export const NEW_PROJ_TEMPLATE = 'NEW_PROJ_TEMPLATE';
|
||||
export const NEW_PROJ_TYPE = 'NEW_PROJ_TYPE';
|
||||
export const NEW_PROJ_HAS_BACKEND = 'NEW_PROJ_HAS_BACKEND';
|
||||
export const NEW_PROJ_HAS_AUTH = 'NEW_PROJ_HAS_AUTH';
|
||||
export const NEW_PROJ_PREVIEW_LAYOUT = 'NEW_PROJ_PREVIEW_LAYOUT';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProjectListScreen, NewProjectRedirect } from './ProjectListScreen';
|
||||
export { ProjectScreen } from './ProjectScreen';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||
import type { LayoutNode } from '@/components/Workspace';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
|
||||
const defaultLayout: LayoutNode = { type: 'panel', id: 'terminal-root', appType: 'terminal-host' };
|
||||
|
||||
export const TerminalScreen = () => {
|
||||
const { user } = useAuth();
|
||||
const [layout, setLayout] = useState<LayoutNode>(defaultLayout);
|
||||
|
||||
if (user?.role !== 'Super Admin') return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView workspace={null} layout={layout} onLayoutChange={setLayout} registry={appRegistry} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye } from 'lucide-react';
|
||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye, FolderKanban, Columns2, PenLine } from 'lucide-react';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { AppRegistry } from '@/components/Workspace';
|
||||
import { useWorkspace } from '@/components/Workspace';
|
||||
@@ -16,6 +16,7 @@ import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||
import { Files } from '../Files';
|
||||
import { Catalog } from 'sounds';
|
||||
import { WorkspaceListApp } from './WorkspaceListApp';
|
||||
import { ProjectListApp } from '../Projects/ProjectListApp';
|
||||
import { ChatLauncher } from '../Home/ChatLauncher';
|
||||
import { widgetRegistry } from 'widgets/widget-registry';
|
||||
import { WidgetPanel } from 'widgets/WidgetPanel';
|
||||
@@ -36,6 +37,39 @@ const FileBrowserWrapper = () => {
|
||||
|
||||
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||
|
||||
const TerminalHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const HostTerminalHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Terminal</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CodeEditorHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Code className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Code Editor</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||
|
||||
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
@@ -105,6 +139,67 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
|
||||
};
|
||||
|
||||
const CommandTerminalWrapper = ({ panelId, command, statePrefix }: { panelId: string; command: string; statePrefix: string }) => {
|
||||
const { workspaceId, cwd } = useWorkspace();
|
||||
const stateKey = workspaceId ? `ws-${statePrefix}-${workspaceId}` : `ws-${statePrefix}-default`;
|
||||
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||
const setTerminalsRef = useRef(setTerminals);
|
||||
setTerminalsRef.current = setTerminals;
|
||||
|
||||
const sessionId = terminals[panelId];
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setTerminals((prev) => ({ ...prev, [panelId]: crypto.randomUUID() }));
|
||||
}
|
||||
}, [panelId, sessionId, setTerminals]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setTerminalsRef.current((prev) => {
|
||||
const { [panelId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
}, [panelId]);
|
||||
|
||||
if (!sessionId) return null;
|
||||
|
||||
const fullCommand = cwd && cwd !== '~' ? `cd ${cwd} && ${command}` : command;
|
||||
|
||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} initialInput={fullCommand} />;
|
||||
};
|
||||
|
||||
const TmuxWrapper = ({ panelId }: { panelId: string }) => (
|
||||
<CommandTerminalWrapper panelId={panelId} command="tmux" statePrefix="tmux" />
|
||||
);
|
||||
|
||||
const NvimWrapper = ({ panelId }: { panelId: string }) => (
|
||||
<CommandTerminalWrapper panelId={panelId} command="nvim" statePrefix="nvim" />
|
||||
);
|
||||
|
||||
const TmuxHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<Columns2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Tmux</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const NvimHeader = () => {
|
||||
const { cwd } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
<PenLine className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Neovim</span>
|
||||
<span className="text-[10px] font-mono truncate opacity-60">{cwd}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type FileViewerChannelState = {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
@@ -134,14 +229,17 @@ const FileViewerWorkspaceProvider = ({ panelId, children }: { panelId: string; c
|
||||
export const appRegistry: AppRegistry = {
|
||||
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
||||
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
|
||||
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> },
|
||||
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
|
||||
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
|
||||
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper },
|
||||
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper },
|
||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp /> },
|
||||
'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider },
|
||||
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180 },
|
||||
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory />, availableOnPanel: false },
|
||||
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog />, availableOnPanel: false },
|
||||
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper, header: CodeEditorHeader },
|
||||
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper, header: TerminalHeader },
|
||||
'tmux': { name: 'Tmux', icon: Columns2, component: TmuxWrapper, header: TmuxHeader },
|
||||
'nvim': { name: 'Neovim', icon: PenLine, component: NvimWrapper, header: NvimHeader },
|
||||
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper, header: HostTerminalHeader, availableOnPanel: false },
|
||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp />, availableOnPanel: false },
|
||||
'project-list': { name: 'Projects', icon: FolderKanban, component: () => <ProjectListApp />, availableOnPanel: false },
|
||||
'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider, availableOnPanel: false },
|
||||
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180, availableOnPanel: false },
|
||||
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
||||
...widgetRegistry,
|
||||
};
|
||||
|
||||
@@ -16,3 +16,5 @@ export * from './Files';
|
||||
export * from './ChatHistory';
|
||||
export * from './CodeEditor';
|
||||
export * from './Workspaces';
|
||||
export * from './Projects';
|
||||
export * from './Terminal';
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import type { UserState } from './types/user-settings';
|
||||
|
||||
const QUERY_KEY = ['PROJECTS_STATE'];
|
||||
|
||||
export function useProjectsState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { data: state = {}, isSuccess } = useQuery<UserState>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<UserState>('/user/projects-state'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const value = key in state ? (state[key] as T) : defaultValue;
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: T | ((prev: T) => T)) => {
|
||||
const currentState = queryClient.getQueryData<UserState>(QUERY_KEY) ?? {};
|
||||
const currentValue = key in currentState ? (currentState[key] as T) : defaultValue;
|
||||
const newValue = typeof update === 'function' ? (update as (prev: T) => T)(currentValue) : update;
|
||||
|
||||
queryClient.setQueryData(QUERY_KEY, { ...currentState, [key]: newValue });
|
||||
|
||||
clientRef.current
|
||||
.patch<UserState>('/user/projects-state', { [key]: newValue })
|
||||
.then((serverState) => {
|
||||
if (serverState) queryClient.setQueryData(QUERY_KEY, serverState);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[key, defaultValue, queryClient],
|
||||
);
|
||||
|
||||
return [value, setValue, isSuccess];
|
||||
}
|
||||
Reference in New Issue
Block a user