diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx index 8139eba3..a5c635c5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx @@ -1,18 +1,13 @@ -import { useMemo } from 'react'; -import { useAuth } from 'hooks/useAuth'; +import { useDock } from 'officerdev'; import { Background } from './Background'; import { Header } from './Header'; -import { Dock, dockItems } from './Dock'; +import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock'; 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], - ); + const { items: visibleItems } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS); return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index f3c037b4..4ca24c27 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -36,7 +36,7 @@ export const Dock = ({ items, className }: DockProps) => { const dockRef = useRef(null); const location = useLocation(); - const isActive = (to: string) => location.pathname.startsWith(to); + const isActive = (to: string) => (to === '/' ? location.pathname === '/' : location.pathname.startsWith(to)); useEffect(() => { const handleMouseMove = (ev: MouseEvent) => { @@ -110,12 +110,12 @@ export const Dock = ({ items, className }: DockProps) => { }; -import { MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react'; +import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react'; -export const dockItems: DockItem[] = [ +export const ALL_DOCK_ITEMS: DockItem[] = [ + { label: 'Home', to: '/', icon: Home, color: '#f59e0b' }, { label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' }, { label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' }, - { label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' }, { label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' }, { label: 'Automation', to: '/automation', icon: Bot, color: '#2dd4bf' }, @@ -124,3 +124,5 @@ export const dockItems: DockItem[] = [ { label: 'Projects', to: '/projects', icon: FolderKanban, color: '#10b981' }, { label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' }, ]; + +export const DEFAULT_DOCK_PATHS = ['/', '/files', '/automation', '/projects', '/workspaces', '/chat']; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx new file mode 100644 index 00000000..1b969c06 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx @@ -0,0 +1,159 @@ +import { useState, useCallback, type DragEvent } from 'react'; +import { X, Plus, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useDock } from 'officerdev'; +import { ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock'; + +type DockPillProps = { + label: string; + path: string; + color: string; + visible: boolean; + onAction: (path: string) => void; + onDragStart: (ev: DragEvent, path: string) => void; +}; + +const DockPill = ({ label, path, color, visible, onAction, onDragStart }: DockPillProps) => ( + onDragStart(ev, path)} + className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium cursor-grab active:cursor-grabbing select-none border border-duck-dark/15 dark:border-foreground/15 bg-background/60 text-duck-dark/80 dark:text-foreground/80 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors" + > + + {label} + + +); + +type DropZoneProps = { + label: string; + children: React.ReactNode; + onDrop: (path: string) => void; +}; + +const DropZone = ({ label, children, onDrop }: DropZoneProps) => { + const [over, setOver] = useState(false); + + const handleDragOver = useCallback((ev: DragEvent) => { + ev.preventDefault(); + setOver(true); + }, []); + + const handleDragLeave = useCallback(() => setOver(false), []); + + const handleDrop = useCallback( + (ev: DragEvent) => { + ev.preventDefault(); + setOver(false); + const path = ev.dataTransfer.getData('text/plain'); + if (path) onDrop(path); + }, + [onDrop], + ); + + return ( +
+ {label} +
+ {children} +
+
+ ); +}; + +export const DockSettings = () => { + const { items, allItems, setItems, reset } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS); + + const visiblePaths = new Set(items.map((i) => i.to)); + const hiddenItems = allItems.filter((i) => !visiblePaths.has(i.to)); + + const onDragStart = useCallback((ev: DragEvent, path: string) => { + ev.dataTransfer.setData('text/plain', path); + ev.dataTransfer.effectAllowed = 'move'; + }, []); + + const addItem = useCallback( + (path: string) => { + if (visiblePaths.has(path)) return; + setItems([...items.map((i) => i.to), path]); + }, + [items, visiblePaths, setItems], + ); + + const removeItem = useCallback( + (path: string) => { + setItems(items.filter((i) => i.to !== path).map((i) => i.to)); + }, + [items, setItems], + ); + + const onDropVisible = useCallback( + (path: string) => { + if (visiblePaths.has(path)) return; + addItem(path); + }, + [visiblePaths, addItem], + ); + + const onDropHidden = useCallback( + (path: string) => { + if (!visiblePaths.has(path)) return; + removeItem(path); + }, + [visiblePaths, removeItem], + ); + + return ( +
+ + {items.length === 0 && Drag items here to show in dock} + {items.map((item) => ( + + ))} + + + + {hiddenItems.length === 0 && All items visible} + {hiddenItems.map((item) => ( + + ))} + + + +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx index ede5605b..b1968943 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/index.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { User, Lock, Globe, Bot } from 'lucide-react'; +import { User, Lock, Globe, Bot, LayoutGrid } from 'lucide-react'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceLayout } from 'officerdev'; @@ -8,6 +8,7 @@ import { UserData } from './UserData'; import { ChangePassword } from './ChangePassword'; import { Languages } from './Languages'; import { AIModels } from './AIModels'; +import { DockSettings } from './DockSettings'; const GLOBAL_KEY = 'PROFILE_SETTINGS_SELECTED'; @@ -16,6 +17,7 @@ const sections: SettingsSection[] = [ { key: 'change-password', icon: Lock, title: 'Change Password', description: 'Update your password', content: }, { key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: }, { key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: }, + { key: 'dock', icon: LayoutGrid, title: 'Dock', description: 'Choose and reorder dock items', content: }, ]; const { Sidebar, Content } = createSettingsPanelComponents({ diff --git a/src/servers/api/dock/dock.ts b/src/servers/api/dock/dock.ts new file mode 100644 index 00000000..5250b730 --- /dev/null +++ b/src/servers/api/dock/dock.ts @@ -0,0 +1,33 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { createRouter } from '../../create-router'; +import { getUserDockFile } from '@@/data-path'; + +const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true }); + +export const dockRouter = createRouter(); + +// GET / — return dock.json, or null if it doesn't exist (frontend uses defaults) +dockRouter.get('/', async (ctx) => { + const email = ctx.get('user').email; + const filePath = getUserDockFile(email); + const file = Bun.file(filePath); + + if (await file.exists()) { + const data = await file.json(); + return ctx.json(data); + } + + return ctx.json(null); +}); + +// PUT / — full replacement of dock paths array +dockRouter.put('/', async (ctx) => { + const email = ctx.get('user').email; + const body = ctx.get('body'); + const filePath = getUserDockFile(email); + + await ensureDir(filePath); + await Bun.write(filePath, JSON.stringify(body, null, 2)); + return ctx.json(body); +}); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index b8d6cbc5..493e1688 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -70,3 +70,5 @@ export const getTmpAttachmentsDir = (email: string) => join(DATA_PATH, email, 'c export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode' | 'pi-mono', sessionId: string) => join(DATA_PATH, email, 'chat_sessions', provider, sessionId, 'attachments'); + +export const getUserDockFile = (email: string) => join(DATA_PATH, email, 'dock', 'dock.json'); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 970566f7..ea6674c0 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -19,6 +19,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { piRestRouter } from './api/pi/rest'; import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; +import { dockRouter } from './api/dock/dock'; import { CustomError } from './custom-errors'; import { userMiddleware, bodyParser } from './_middlewares'; @@ -59,6 +60,7 @@ protectedRouter.route('/workspaces', workspacesRouter); protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/dev-server', devServerRouter); +protectedRouter.route('/dock', dockRouter); protectedRouter.route('/', piRestRouter); honoServer.route('/api', protectedRouter); diff --git a/src/workspaces/officerdev/src/hooks/index.ts b/src/workspaces/officerdev/src/hooks/index.ts index 5ccfdca9..06b0e444 100644 --- a/src/workspaces/officerdev/src/hooks/index.ts +++ b/src/workspaces/officerdev/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useFilesAPI'; export * from './useFileViewerPanels'; export * from './usePiChat'; +export * from './useDock'; diff --git a/src/workspaces/officerdev/src/hooks/useDock.ts b/src/workspaces/officerdev/src/hooks/useDock.ts new file mode 100644 index 00000000..5de7da2b --- /dev/null +++ b/src/workspaces/officerdev/src/hooks/useDock.ts @@ -0,0 +1,54 @@ +import { useCallback, useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; +import { useAuth } from 'hooks/useAuth'; + +const QUERY_KEY = ['DOCK']; + +type DockItemLike = { + to: string; + role?: string; +}; + +export function useDock(allDockItems: T[], defaultPaths?: string[]) { + const client = useClient(); + const { user, isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + + const { data: dockPaths = null } = useQuery({ + queryKey: QUERY_KEY, + enabled: isAuthenticated, + queryFn: () => client.get('/dock'), + staleTime: Infinity, + }); + + const fallbackPaths = useMemo(() => defaultPaths ?? allDockItems.map((i) => i.to), [defaultPaths, allDockItems]); + const activePaths = dockPaths ?? fallbackPaths; + + const items = useMemo(() => { + const byPath = new Map(allDockItems.map((item) => [item.to, item])); + return activePaths + .map((path) => byPath.get(path)) + .filter((item): item is T => !!item && (!item.role || item.role === user?.role)); + }, [activePaths, allDockItems, user?.role]); + + const allItems = useMemo( + () => allDockItems.filter((item) => !item.role || item.role === user?.role), + [allDockItems, user?.role], + ); + + const setItems = useCallback( + (paths: string[]) => { + queryClient.setQueryData(QUERY_KEY, paths); + client.put('/dock', paths); + }, + [client, queryClient], + ); + + const reset = useCallback(() => { + queryClient.setQueryData(QUERY_KEY, null); + client.put('/dock', null); + }, [client, queryClient]); + + return { items, allItems, setItems, reset }; +}