diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 5f998f52..fa7aeb68 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -54,6 +54,10 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx index 9aa7741c..359a82a7 100644 --- a/src/apps/officer-web/Screens/Dashboard/Home/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Home/index.tsx @@ -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 }, ], }; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index dd5d063c..8139eba3 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -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 (
- +
{children}
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index 3ea9b48c..cf94d577 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -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' }, ]; diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListApp.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListApp.tsx new file mode 100644 index 00000000..955b4938 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListApp.tsx @@ -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 = { + '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('projects', []); + const [selected, setSelected] = useGlobal(SELECTED_PROJECT, null); + const [, setCreating] = useGlobal(CREATING_PROJECT, false); + const [, setEditing] = useGlobal(EDITING_PROJECT, null); + const [, setName] = useGlobal(NEW_PROJ_NAME, ''); + const [, setDescription] = useGlobal(NEW_PROJ_DESC, ''); + const [, setTemplateIdx] = useGlobal(NEW_PROJ_TEMPLATE, 0); + const [, setProjectType] = useGlobal(NEW_PROJ_TYPE, 'app'); + const [, setHasBackend] = useGlobal(NEW_PROJ_HAS_BACKEND, false); + const [, setHasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false); + + const [search, setSearch] = useState(''); + const [deleting, setDeleting] = useState(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>(['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 ( +
+
+ + + Projects + + +
+
+ + 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" + /> +
+
+ {filtered.map((p) => ( +
handleClick(p)} + > + + {p.name} + {PROJECT_TYPE_LABELS[p.projectType]} + {isProjectsPage && ( + <> + + + + )} +
+ ))} + {filtered.length === 0 && ( +

+ {search ? 'No matches' : 'No projects yet'} +

+ )} +
+ + { if (!open) setDeleting(null); }}> + + + Delete project + + Are you sure you want to delete {deleting?.name}? This action cannot be undone. + + + + Cancel + + Delete + + + + +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx new file mode 100644 index 00000000..b8c07988 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Projects/ProjectListScreen.tsx @@ -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
; + } + const isH = node.direction === 'horizontal'; + return ( +
+ {node.children.map((child) => ( +
+ +
+ ))} +
+ ); +}; + +const TemplateCell = ({ index }: { index: number }) => { + const tpl = templates[index]!; + const [selected, setSelected] = useGlobal(NEW_PROJ_TEMPLATE, 0); + const node = tpl.layout(); + const isSelected = selected === index; + + return ( +
+ +
+ ); +}; + +// --- 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: () => }]), +); + +const TemplatePanel = () => ( + {}} 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(NEW_PROJ_NAME, ''); + const [description, setDescription] = useGlobal(NEW_PROJ_DESC, ''); + const [projectType, setProjectType] = useGlobal(NEW_PROJ_TYPE, 'app'); + const [hasBackend, setHasBackend] = useGlobal(NEW_PROJ_HAS_BACKEND, false); + const [hasAuth, setHasAuth] = useGlobal(NEW_PROJ_HAS_AUTH, false); + + return ( +
+
+
+ + 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" + /> +
+
+ +