useDock
This commit is contained in:
@@ -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 (
|
||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||
|
||||
@@ -36,7 +36,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
const dockRef = useRef<HTMLDivElement | null>(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'];
|
||||
|
||||
@@ -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) => (
|
||||
<span
|
||||
draggable
|
||||
onDragStart={(ev) => 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"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: color }} />
|
||||
{label}
|
||||
<button
|
||||
onClick={() => onAction(path)}
|
||||
className="ml-0.5 p-0.5 rounded-full hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
|
||||
>
|
||||
{visible ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wide">{label}</span>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`min-h-[48px] p-2 rounded-lg border border-dashed transition-colors flex flex-wrap gap-1.5 ${over ? 'border-duck-teal bg-duck-teal/5' : 'border-duck-dark/15 dark:border-foreground/15'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="grid gap-4">
|
||||
<DropZone label="Visible" onDrop={onDropVisible}>
|
||||
{items.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">Drag items here to show in dock</span>}
|
||||
{items.map((item) => (
|
||||
<DockPill
|
||||
key={item.to}
|
||||
label={item.label}
|
||||
path={item.to}
|
||||
color={item.color}
|
||||
visible
|
||||
onAction={removeItem}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<DropZone label="Hidden" onDrop={onDropHidden}>
|
||||
{hiddenItems.length === 0 && <span className="text-xs text-duck-dark/30 dark:text-foreground/30 py-1">All items visible</span>}
|
||||
{hiddenItems.map((item) => (
|
||||
<DockPill
|
||||
key={item.to}
|
||||
label={item.label}
|
||||
path={item.to}
|
||||
color={item.color}
|
||||
visible={false}
|
||||
onAction={addItem}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
</DropZone>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={reset}
|
||||
className="w-full h-9 text-sm cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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: <ChangePassword /> },
|
||||
{ key: 'ai-models', icon: Bot, title: 'AI Models', description: 'Default models for chat, projects, and tasks', content: <AIModels /> },
|
||||
{ key: 'languages', icon: Globe, title: 'Languages', description: 'Spoken, default, and translation', content: <Languages /> },
|
||||
{ key: 'dock', icon: LayoutGrid, title: 'Dock', description: 'Choose and reorder dock items', content: <DockSettings /> },
|
||||
];
|
||||
|
||||
const { Sidebar, Content } = createSettingsPanelComponents({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useFilesAPI';
|
||||
export * from './useFileViewerPanels';
|
||||
export * from './usePiChat';
|
||||
export * from './useDock';
|
||||
|
||||
@@ -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<T extends DockItemLike>(allDockItems: T[], defaultPaths?: string[]) {
|
||||
const client = useClient();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: dockPaths = null } = useQuery<string[] | null>({
|
||||
queryKey: QUERY_KEY,
|
||||
enabled: isAuthenticated,
|
||||
queryFn: () => client.get<string[] | null>('/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 };
|
||||
}
|
||||
Reference in New Issue
Block a user