Workspaces
This commit is contained in:
@@ -53,6 +53,8 @@ export function App() {
|
|||||||
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
<Route path="/tasks" element={<Dashboard.Tasks />} />
|
||||||
<Route path="/processes" element={<Dashboard.Processes />} />
|
<Route path="/processes" element={<Dashboard.Processes />} />
|
||||||
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
<Route path="/task-logs" element={<Dashboard.TaskLogs />} />
|
||||||
|
<Route path="/workspaces" element={<Dashboard.WorkspaceListScreen />} />
|
||||||
|
<Route path="/workspaces/:id" element={<Dashboard.WorkspaceScreen />} />
|
||||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,26 +1,52 @@
|
|||||||
import { Workspace, createWorkspaceDefaults } from '@/components/Workspace';
|
|
||||||
import { useUserState } from '@/state/useUserState';
|
import { useUserState } from '@/state/useUserState';
|
||||||
import { ChatLauncher } from './ChatLauncher';
|
import { WorkspaceView } from '@/components/Workspace';
|
||||||
import { FileBrowserWidget as FileBrowser } from '@/Screens/Dashboard/Files';
|
import type { LayoutNode } from '@/components/Workspace';
|
||||||
import { ChatHistoryWidget as ChatHistory } from '@/Screens/Dashboard/ChatHistory';
|
import { widgetRegistry } from '@/Screens/Dashboard/Workspaces/widget-registry';
|
||||||
import { Catalog } from 'sounds';
|
|
||||||
|
|
||||||
const WIDGET_IDS = ['chat-launcher', 'file-browser', 'chat-history', 'sound-library'] as const;
|
const DEFAULT_HOME_LAYOUT: LayoutNode = {
|
||||||
const DEFAULTS = createWorkspaceDefaults([...WIDGET_IDS]);
|
type: 'group',
|
||||||
|
id: 'home-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
size: 50,
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'home-left',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{ size: 50, node: { type: 'panel', id: 'home-tl', widgetType: 'chat-launcher' } },
|
||||||
|
{ size: 50, node: { type: 'panel', id: 'home-bl', widgetType: 'file-browser' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
size: 50,
|
||||||
|
node: {
|
||||||
|
type: 'group',
|
||||||
|
id: 'home-right',
|
||||||
|
direction: 'vertical',
|
||||||
|
children: [
|
||||||
|
{ size: 50, node: { type: 'panel', id: 'home-tr', widgetType: 'chat-history' } },
|
||||||
|
{ size: 50, node: { type: 'panel', id: 'home-br', widgetType: 'sound-library' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const HomeScreen = () => {
|
export const HomeScreen = () => {
|
||||||
const [state, setState] = useUserState('home-layout', DEFAULTS);
|
const [layout, setLayout] = useUserState<LayoutNode>('home-workspace-layout', DEFAULT_HOME_LAYOUT);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Workspace
|
<div className="h-full w-full">
|
||||||
widgets={{
|
<WorkspaceView
|
||||||
'chat-launcher': <ChatLauncher />,
|
workspace={null}
|
||||||
'file-browser': <FileBrowser />,
|
name="Home"
|
||||||
'chat-history': <ChatHistory />,
|
layout={layout}
|
||||||
'sound-library': <Catalog />,
|
onLayoutChange={setLayout}
|
||||||
}}
|
registry={widgetRegistry}
|
||||||
state={state}
|
/>
|
||||||
onChange={setState}
|
</div>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const Dock = ({ items, className }: DockProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
import { Terminal, TerminalSquare, FileText, FolderOpen, Code } from 'lucide-react';
|
import { Terminal, TerminalSquare, FileText, FolderOpen, Code, LayoutGrid } from 'lucide-react';
|
||||||
import { Sparkles, ClipboardList, ScrollText, Workflow } from 'lucide-react';
|
import { Sparkles, ClipboardList, ScrollText, Workflow } from 'lucide-react';
|
||||||
|
|
||||||
export const dockItems: DockItem[] = [
|
export const dockItems: DockItem[] = [
|
||||||
@@ -104,4 +104,5 @@ export const dockItems: DockItem[] = [
|
|||||||
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
|
{ label: 'Tasks', to: '/tasks', icon: ClipboardList, color: '#fb923c' },
|
||||||
{ label: 'Processes', to: '/processes', icon: Workflow, color: '#2dd4bf' },
|
{ label: 'Processes', to: '/processes', icon: Workflow, color: '#2dd4bf' },
|
||||||
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
{ label: 'Logs', to: '/task-logs', icon: ScrollText, color: '#94a3b8' },
|
||||||
|
{ label: 'Workspaces', to: '/workspaces', icon: LayoutGrid, color: '#8b5cf6' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { Plus, Trash2, LayoutGrid, ArrowRight } from 'lucide-react';
|
||||||
|
import { useUserState } from '@/state/useUserState';
|
||||||
|
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||||
|
import { Widget } from '@/components/Widget';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export const WorkspaceListScreen = () => {
|
||||||
|
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
|
const createWorkspace = () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const ws: WorkspaceDefinition = {
|
||||||
|
id: `ws-${Date.now()}`,
|
||||||
|
name,
|
||||||
|
cwd: '~',
|
||||||
|
};
|
||||||
|
setWorkspaces((prev) => [...prev, ws]);
|
||||||
|
setNewName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteWorkspace = (id: string) => {
|
||||||
|
setWorkspaces((prev) => prev.filter((ws) => ws.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-start justify-center p-8">
|
||||||
|
<Widget title="Workspaces" className="w-full max-w-lg">
|
||||||
|
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(ev) => setNewName(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
createWorkspace();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="New workspace name..."
|
||||||
|
className="flex-1 rounded-lg border border-duck-dark/20 bg-white px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={createWorkspace}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{workspaces.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-8 text-duck-dark/40">
|
||||||
|
<LayoutGrid className="h-8 w-8" />
|
||||||
|
<p className="text-sm">No workspaces yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{workspaces.map((ws) => (
|
||||||
|
<li
|
||||||
|
key={ws.id}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-duck-dark/5 group"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||||
|
<Link to={`/workspaces/${ws.id}`} className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-duck-dark truncate block">{ws.name}</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteWorkspace(ws.id)}
|
||||||
|
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>
|
||||||
|
<Link
|
||||||
|
to={`/workspaces/${ws.id}`}
|
||||||
|
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { LayoutGrid, ArrowRight } from 'lucide-react';
|
||||||
|
import { useUserState } from '@/state/useUserState';
|
||||||
|
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||||
|
|
||||||
|
export const WorkspaceListWidget = () => {
|
||||||
|
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||||
|
{workspaces.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-6 text-duck-dark/40">
|
||||||
|
<LayoutGrid className="h-6 w-6" />
|
||||||
|
<p className="text-xs">No workspaces</p>
|
||||||
|
<Link to="/workspaces" className="text-xs text-duck-teal hover:underline">
|
||||||
|
Create one
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{workspaces.map((ws) => (
|
||||||
|
<li key={ws.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
|
||||||
|
<Link to={`/workspaces/${ws.id}`} className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<LayoutGrid className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
||||||
|
<span className="text-sm text-duck-dark truncate">{ws.name}</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/workspaces/${ws.id}`}
|
||||||
|
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useParams, Navigate } from 'react-router';
|
||||||
|
import { useUserState } from '@/state/useUserState';
|
||||||
|
import { WorkspaceView, createDefaultLayout } from '@/components/Workspace';
|
||||||
|
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||||
|
import { widgetRegistry } from './widget-registry';
|
||||||
|
|
||||||
|
export const WorkspaceScreen = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||||
|
const workspace = workspaces.find((ws) => ws.id === id);
|
||||||
|
|
||||||
|
if (!workspace) return <Navigate to="/workspaces" replace />;
|
||||||
|
|
||||||
|
return <WorkspaceScreenInner workspace={workspace} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspaceScreenInner = ({ workspace }: { workspace: WorkspaceDefinition }) => {
|
||||||
|
const [layout, setLayout] = useUserState<LayoutNode>(`ws-layout-${workspace.id}`, createDefaultLayout());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<WorkspaceView
|
||||||
|
workspace={workspace}
|
||||||
|
layout={layout}
|
||||||
|
onLayoutChange={setLayout}
|
||||||
|
registry={widgetRegistry}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { WorkspaceListScreen } from './WorkspaceListScreen';
|
||||||
|
export { WorkspaceScreen } from './WorkspaceScreen';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, LayoutGrid } from 'lucide-react';
|
||||||
|
import type { WidgetRegistry } from '@/components/Workspace';
|
||||||
|
import { CodeEditorView } from 'widgets/CodeEditor';
|
||||||
|
import { TerminalView } from 'widgets/Terminal';
|
||||||
|
import { ChatLauncher } from '../Home/ChatLauncher';
|
||||||
|
import { ChatHistoryWidget as ChatHistory } from '../ChatHistory';
|
||||||
|
import { FileBrowserWidget as FileBrowser } from '../Files';
|
||||||
|
import { Catalog } from 'sounds';
|
||||||
|
import { WorkspaceListWidget } from './WorkspaceListWidget';
|
||||||
|
|
||||||
|
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||||
|
|
||||||
|
const TerminalWrapper = () => <TerminalView className="h-full w-full p-2" />;
|
||||||
|
|
||||||
|
export const widgetRegistry: WidgetRegistry = {
|
||||||
|
'chat-launcher': { name: 'Chat', icon: MessageSquare, component: () => <ChatLauncher /> },
|
||||||
|
'file-browser': { name: 'File Browser', icon: FolderOpen, component: () => <FileBrowser /> },
|
||||||
|
'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 },
|
||||||
|
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListWidget /> },
|
||||||
|
};
|
||||||
@@ -14,3 +14,4 @@ export * from './Terminal';
|
|||||||
export * from './Files';
|
export * from './Files';
|
||||||
export * from './ChatHistory';
|
export * from './ChatHistory';
|
||||||
export * from './CodeEditor';
|
export * from './CodeEditor';
|
||||||
|
export * from './Workspaces';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CSSProperties, ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
|
import type { CSSProperties, ComponentPropsWithoutRef, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
|
||||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { ChevronDown, ChevronUp, Minus, Plus } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Minus, Plus, X } from 'lucide-react';
|
||||||
import { cn } from 'helpers/cn';
|
import { cn } from 'helpers/cn';
|
||||||
import { Card } from './Card';
|
import { Card } from './Card';
|
||||||
|
|
||||||
@@ -10,9 +10,10 @@ type WidgetProps = ComponentPropsWithoutRef<'div'> & {
|
|||||||
resizable?: boolean;
|
resizable?: boolean;
|
||||||
collapsible?: boolean | { title: string; icon?: LucideIcon };
|
collapsible?: boolean | { title: string; icon?: LucideIcon };
|
||||||
moveable?: boolean;
|
moveable?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Widget = ({ title, className, style, resizable, collapsible, moveable, children, ...props }: WidgetProps) => {
|
export const Widget = ({ title, className, style, resizable, collapsible, moveable, onClose, children, ...props }: WidgetProps) => {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const [minimized, setMinimized] = useState(false);
|
const [minimized, setMinimized] = useState(false);
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
@@ -120,13 +121,24 @@ export const Widget = ({ title, className, style, resizable, collapsible, moveab
|
|||||||
>
|
>
|
||||||
<div data-widget-header className="flex h-8 items-center gap-12 px-3 select-none cursor-grab">
|
<div data-widget-header className="flex h-8 items-center gap-12 px-3 select-none cursor-grab">
|
||||||
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
|
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
|
||||||
<button
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
type="button"
|
<button
|
||||||
className="ml-auto p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
type="button"
|
||||||
onClick={() => toggleMinimized()}
|
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
>
|
onClick={() => toggleMinimized()}
|
||||||
{minimized ? <Plus size={14} /> : <Minus size={14} />}
|
>
|
||||||
</button>
|
{minimized ? <Plus size={14} /> : <Minus size={14} />}
|
||||||
|
</button>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!minimized && (
|
{!minimized && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,409 +0,0 @@
|
|||||||
import type { ReactNode, PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from 'react';
|
|
||||||
import { useCallback, useRef, useState } from 'react';
|
|
||||||
import { Columns, Grid2x2, Move } from 'lucide-react';
|
|
||||||
import { cn } from 'helpers/cn';
|
|
||||||
|
|
||||||
// --- Public Types ---
|
|
||||||
|
|
||||||
export type LayoutMode = 'free' | 'spectacle' | 'hyprland';
|
|
||||||
export type FreePosition = { x: number; y: number; w: number; h: number };
|
|
||||||
export type SpectacleZone = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
||||||
|
|
||||||
export type WorkspaceState = {
|
|
||||||
mode: LayoutMode;
|
|
||||||
free: Record<string, FreePosition>;
|
|
||||||
spectacle: Record<string, SpectacleZone>;
|
|
||||||
hyprland: { master: string; stack: string[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkspaceOnChange = (update: WorkspaceState | ((prev: WorkspaceState) => WorkspaceState)) => void;
|
|
||||||
|
|
||||||
type WorkspaceProps = {
|
|
||||||
widgets: Record<string, ReactNode>;
|
|
||||||
state: WorkspaceState;
|
|
||||||
onChange: WorkspaceOnChange;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Defaults Helper ---
|
|
||||||
|
|
||||||
const SPECTACLE_ZONES: SpectacleZone[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
||||||
|
|
||||||
export function createWorkspaceDefaults(widgetIds: string[]): WorkspaceState {
|
|
||||||
return {
|
|
||||||
mode: 'spectacle',
|
|
||||||
free: Object.fromEntries(
|
|
||||||
widgetIds.map((id, i) => [
|
|
||||||
id,
|
|
||||||
{ x: 40 + (i % 2) * 440, y: 40 + Math.floor(i / 2) * 320, w: 420, h: 300 },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
spectacle: Object.fromEntries(
|
|
||||||
widgetIds.map((id, i) => [id, SPECTACLE_ZONES[i % SPECTACLE_ZONES.length]!]),
|
|
||||||
),
|
|
||||||
hyprland: {
|
|
||||||
master: widgetIds[0]!,
|
|
||||||
stack: widgetIds.slice(1),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Internal Layout Hook ---
|
|
||||||
|
|
||||||
function useWorkspaceLayout(widgetIds: string[], state: WorkspaceState, onChange: WorkspaceOnChange) {
|
|
||||||
const [zOrder, setZOrder] = useState<string[]>(() => [...widgetIds]);
|
|
||||||
const [dragOverride, setDragOverride] = useState<{ id: string; pos: FreePosition } | null>(null);
|
|
||||||
|
|
||||||
const setMode = useCallback(
|
|
||||||
(mode: LayoutMode) => onChange((prev) => ({ ...prev, mode })),
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setFreePositions = useCallback(
|
|
||||||
(positions: Record<string, FreePosition>) =>
|
|
||||||
onChange((prev) => ({ ...prev, mode: 'free' as const, free: positions })),
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateFreePosition = useCallback(
|
|
||||||
(id: string, pos: Partial<FreePosition>) =>
|
|
||||||
onChange((prev) => {
|
|
||||||
const base = prev.free[id] ?? { x: 0, y: 0, w: 420, h: 300 };
|
|
||||||
const updated: FreePosition = { ...base, ...pos };
|
|
||||||
return { ...prev, free: { ...prev.free, [id]: updated } };
|
|
||||||
}),
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const swapZones = useCallback(
|
|
||||||
(a: string, b: string) => {
|
|
||||||
if (a === b) return;
|
|
||||||
onChange((prev) => {
|
|
||||||
const zoneA = prev.spectacle[a];
|
|
||||||
const zoneB = prev.spectacle[b];
|
|
||||||
if (!zoneA || !zoneB) return prev;
|
|
||||||
return { ...prev, spectacle: { ...prev.spectacle, [a]: zoneB, [b]: zoneA } };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const promoteMaster = useCallback(
|
|
||||||
(id: string) =>
|
|
||||||
onChange((prev) => {
|
|
||||||
if (prev.hyprland.master === id) return prev;
|
|
||||||
const oldMaster = prev.hyprland.master;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
hyprland: {
|
|
||||||
master: id,
|
|
||||||
stack: prev.hyprland.stack.map((s) => (s === id ? oldMaster : s)),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const bringToFront = useCallback(
|
|
||||||
(id: string) =>
|
|
||||||
setZOrder((prev) => {
|
|
||||||
if (prev[prev.length - 1] === id) return prev;
|
|
||||||
return [...prev.filter((w) => w !== id), id];
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: state.mode,
|
|
||||||
setMode,
|
|
||||||
setFreePositions,
|
|
||||||
free: state.free,
|
|
||||||
updateFreePosition,
|
|
||||||
spectacle: state.spectacle,
|
|
||||||
swapZones,
|
|
||||||
hyprland: state.hyprland,
|
|
||||||
promoteMaster,
|
|
||||||
zOrder,
|
|
||||||
bringToFront,
|
|
||||||
dragOverride,
|
|
||||||
setDragOverride,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Workspace Component ---
|
|
||||||
|
|
||||||
export const Workspace = ({ widgets, state, onChange }: WorkspaceProps) => {
|
|
||||||
const widgetIds = Object.keys(widgets);
|
|
||||||
const layout = useWorkspaceLayout(widgetIds, state, onChange);
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleModeChange = (newMode: LayoutMode) => {
|
|
||||||
if (newMode === 'free' && canvasRef.current) {
|
|
||||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
|
||||||
const positions: Record<string, FreePosition> = {};
|
|
||||||
for (const id of widgetIds) {
|
|
||||||
const wrapper = canvasRef.current.querySelector<HTMLElement>(`[data-widget-id="${id}"]`);
|
|
||||||
const el = (wrapper?.firstElementChild as HTMLElement | null) ?? wrapper;
|
|
||||||
if (el) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
positions[id] = {
|
|
||||||
x: rect.left - canvasRect.left,
|
|
||||||
y: rect.top - canvasRect.top,
|
|
||||||
w: rect.width,
|
|
||||||
h: rect.height,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
positions[id] = layout.free[id] ?? { x: 0, y: 0, w: 420, h: 300 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
layout.setFreePositions(positions);
|
|
||||||
} else {
|
|
||||||
layout.setMode(newMode);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={canvasRef} className="relative h-full w-full overflow-hidden">
|
|
||||||
{layout.mode === 'free' && (
|
|
||||||
<FreeLayout widgets={widgets} widgetIds={widgetIds} layout={layout} canvasRef={canvasRef} />
|
|
||||||
)}
|
|
||||||
{layout.mode === 'spectacle' && (
|
|
||||||
<SpectacleLayout widgets={widgets} widgetIds={widgetIds} layout={layout} canvasRef={canvasRef} />
|
|
||||||
)}
|
|
||||||
{layout.mode === 'hyprland' && <HyprlandLayout widgets={widgets} layout={layout} />}
|
|
||||||
<ModeSwitcher mode={layout.mode} onChange={handleModeChange} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Sub-component Props ---
|
|
||||||
|
|
||||||
type LayoutProps = {
|
|
||||||
widgets: Record<string, ReactNode>;
|
|
||||||
widgetIds: string[];
|
|
||||||
layout: ReturnType<typeof useWorkspaceLayout>;
|
|
||||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Free Mode ---
|
|
||||||
|
|
||||||
const FreeLayout = ({ widgets, widgetIds, layout, canvasRef }: LayoutProps) => {
|
|
||||||
const dragRef = useRef<{
|
|
||||||
id: string;
|
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
originX: number;
|
|
||||||
originY: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const onPointerDown = (id: string, ev: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
layout.bringToFront(id);
|
|
||||||
|
|
||||||
const target = ev.target as HTMLElement;
|
|
||||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
|
||||||
if (ev.detail === 2) return;
|
|
||||||
|
|
||||||
const pos = layout.free[id];
|
|
||||||
if (!pos) return;
|
|
||||||
dragRef.current = {
|
|
||||||
id,
|
|
||||||
startX: ev.clientX,
|
|
||||||
startY: ev.clientY,
|
|
||||||
originX: pos.x,
|
|
||||||
originY: pos.y,
|
|
||||||
w: pos.w,
|
|
||||||
h: pos.h,
|
|
||||||
};
|
|
||||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
|
||||||
ev.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
const d = dragRef.current;
|
|
||||||
if (!d || !canvasRef.current) return;
|
|
||||||
|
|
||||||
const canvas = canvasRef.current.getBoundingClientRect();
|
|
||||||
const newX = Math.max(0, Math.min(d.originX + ev.clientX - d.startX, canvas.width - d.w));
|
|
||||||
const newY = Math.max(0, Math.min(d.originY + ev.clientY - d.startY, canvas.height - 40));
|
|
||||||
layout.setDragOverride({ id: d.id, pos: { x: newX, y: newY, w: d.w, h: d.h } });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerUp = () => {
|
|
||||||
const d = dragRef.current;
|
|
||||||
if (!d) return;
|
|
||||||
|
|
||||||
if (layout.dragOverride?.id === d.id) {
|
|
||||||
layout.updateFreePosition(d.id, layout.dragOverride.pos);
|
|
||||||
layout.setDragOverride(null);
|
|
||||||
}
|
|
||||||
dragRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{widgetIds.map((id) => {
|
|
||||||
const pos = layout.dragOverride?.id === id ? layout.dragOverride.pos : layout.free[id];
|
|
||||||
if (!pos) return null;
|
|
||||||
const zIndex = layout.zOrder.indexOf(id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={id}
|
|
||||||
data-widget-id={id}
|
|
||||||
className="absolute"
|
|
||||||
style={{ left: pos.x, top: pos.y, width: pos.w, height: pos.h, zIndex }}
|
|
||||||
onPointerDown={(ev) => onPointerDown(id, ev)}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
>
|
|
||||||
{widgets[id]}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Spectacle Mode ---
|
|
||||||
|
|
||||||
const ZONE_GRID_AREA: Record<SpectacleZone, string> = {
|
|
||||||
'top-left': '1 / 1 / 2 / 2',
|
|
||||||
'top-right': '1 / 2 / 2 / 3',
|
|
||||||
'bottom-left': '2 / 1 / 3 / 2',
|
|
||||||
'bottom-right': '2 / 2 / 3 / 3',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SpectacleLayout = ({ widgets, widgetIds, layout, canvasRef }: LayoutProps) => {
|
|
||||||
const [dragTarget, setDragTarget] = useState<SpectacleZone | null>(null);
|
|
||||||
const dragRef = useRef<{ id: string } | null>(null);
|
|
||||||
|
|
||||||
const getZoneFromPoint = (clientX: number, clientY: number): SpectacleZone | null => {
|
|
||||||
if (!canvasRef.current) return null;
|
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
|
||||||
const x = clientX - rect.left;
|
|
||||||
const y = clientY - rect.top;
|
|
||||||
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null;
|
|
||||||
const col = x < rect.width / 2 ? 'left' : 'right';
|
|
||||||
const row = y < rect.height / 2 ? 'top' : 'bottom';
|
|
||||||
return `${row}-${col}` as SpectacleZone;
|
|
||||||
};
|
|
||||||
|
|
||||||
const widgetByZone: Record<string, string> = {};
|
|
||||||
for (const id of widgetIds) {
|
|
||||||
const zone = layout.spectacle[id];
|
|
||||||
if (zone) widgetByZone[zone] = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerDown = (id: string, ev: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
const target = ev.target as HTMLElement;
|
|
||||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
|
||||||
if (ev.detail === 2) return;
|
|
||||||
|
|
||||||
dragRef.current = { id };
|
|
||||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
|
||||||
ev.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!dragRef.current) return;
|
|
||||||
const zone = getZoneFromPoint(ev.clientX, ev.clientY);
|
|
||||||
setDragTarget(zone && zone !== layout.spectacle[dragRef.current.id] ? zone : null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerUp = () => {
|
|
||||||
const d = dragRef.current;
|
|
||||||
if (d && dragTarget) {
|
|
||||||
const occupant = widgetByZone[dragTarget];
|
|
||||||
if (occupant && occupant !== d.id) {
|
|
||||||
layout.swapZones(d.id, occupant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDragTarget(null);
|
|
||||||
dragRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="grid h-full w-full gap-2 p-2"
|
|
||||||
style={{ gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr' }}
|
|
||||||
>
|
|
||||||
{widgetIds.map((id) => {
|
|
||||||
const zone = layout.spectacle[id];
|
|
||||||
if (!zone) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={id}
|
|
||||||
data-widget-id={id}
|
|
||||||
className="h-full w-full overflow-hidden"
|
|
||||||
style={{ gridArea: ZONE_GRID_AREA[zone] }}
|
|
||||||
onPointerDown={(ev) => onPointerDown(id, ev)}
|
|
||||||
onPointerMove={onPointerMove}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
>
|
|
||||||
{widgets[id]}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{dragTarget && (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none z-10 rounded-lg border-2 border-duck-teal/30 bg-duck-teal/10"
|
|
||||||
style={{ gridArea: ZONE_GRID_AREA[dragTarget] }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Hyprland Mode ---
|
|
||||||
|
|
||||||
const HyprlandLayout = ({ widgets, layout }: Omit<LayoutProps, 'canvasRef' | 'widgetIds'>) => {
|
|
||||||
const onDoubleClick = (id: string, ev: ReactMouseEvent<HTMLDivElement>) => {
|
|
||||||
const target = ev.target as HTMLElement;
|
|
||||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
|
||||||
layout.promoteMaster(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full gap-2 p-2">
|
|
||||||
<div className="min-w-0 flex-1" data-widget-id={layout.hyprland.master}>
|
|
||||||
{widgets[layout.hyprland.master]}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
||||||
{layout.hyprland.stack.map((id) => (
|
|
||||||
<div key={id} className="min-h-0 flex-1" data-widget-id={id} onDoubleClick={(ev) => onDoubleClick(id, ev)}>
|
|
||||||
{widgets[id]}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Mode Switcher ---
|
|
||||||
|
|
||||||
const MODE_OPTIONS: { mode: LayoutMode; icon: typeof Move; label: string }[] = [
|
|
||||||
{ mode: 'spectacle', icon: Grid2x2, label: 'Snap' },
|
|
||||||
{ mode: 'free', icon: Move, label: 'Free' },
|
|
||||||
{ mode: 'hyprland', icon: Columns, label: 'Auto' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ModeSwitcher = ({ mode, onChange }: { mode: LayoutMode; onChange: (m: LayoutMode) => void }) => (
|
|
||||||
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-1 rounded-full border border-border bg-background/80 p-1 backdrop-blur-sm">
|
|
||||||
{MODE_OPTIONS.map(({ mode: m, icon: Icon, label }) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(m)}
|
|
||||||
title={label}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all cursor-pointer',
|
|
||||||
mode === m ? 'bg-white text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Columns, Rows, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
type LayoutEditorProps = {
|
||||||
|
panelId: string;
|
||||||
|
hasWidget: boolean;
|
||||||
|
isLastPanel: boolean;
|
||||||
|
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||||
|
onRemove: (panelId: string) => void;
|
||||||
|
onClearWidget: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LayoutEditor = ({ panelId, hasWidget, isLastPanel, onSplit, onRemove, onClearWidget }: LayoutEditorProps) => (
|
||||||
|
<div className="absolute top-1 right-1 flex gap-1 z-10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
||||||
|
onClick={() => onSplit(panelId, 'horizontal')}
|
||||||
|
title="Split Horizontal"
|
||||||
|
>
|
||||||
|
<Columns size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
||||||
|
onClick={() => onSplit(panelId, 'vertical')}
|
||||||
|
title="Split Vertical"
|
||||||
|
>
|
||||||
|
<Rows size={14} />
|
||||||
|
</button>
|
||||||
|
{hasWidget && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded bg-duck-teal/10 border border-duck-teal/20 text-duck-teal/70 hover:text-duck-teal hover:bg-duck-teal/20 cursor-pointer"
|
||||||
|
onClick={onClearWidget}
|
||||||
|
title="Clear Widget"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isLastPanel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-1 rounded bg-red-400/10 border border-red-400/30 text-red-400/70 hover:text-red-400 hover:bg-red-400/20 cursor-pointer"
|
||||||
|
onClick={() => onRemove(panelId)}
|
||||||
|
title="Remove Panel"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { LayoutPanel, WidgetRegistry } from './types';
|
||||||
|
import { WidgetPicker } from './WidgetPicker';
|
||||||
|
import { LayoutEditor } from './LayoutEditor';
|
||||||
|
|
||||||
|
type PanelSlotProps = {
|
||||||
|
panel: LayoutPanel;
|
||||||
|
registry: WidgetRegistry;
|
||||||
|
editing: boolean;
|
||||||
|
isLastPanel: boolean;
|
||||||
|
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||||
|
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||||
|
onRemove: (panelId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PanelSlot = ({ panel, registry, editing, isLastPanel, onSetWidget, onSplit, onRemove }: PanelSlotProps) => {
|
||||||
|
const entry = panel.widgetType ? registry[panel.widgetType] : null;
|
||||||
|
const WidgetComponent = entry?.component;
|
||||||
|
|
||||||
|
if (!editing && !WidgetComponent) return null;
|
||||||
|
|
||||||
|
if (!WidgetComponent) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4 rounded-lg border backdrop-blur-xl"
|
||||||
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||||
|
>
|
||||||
|
<WidgetPicker registry={registry} onSelect={(type) => onSetWidget(panel.id, type)} />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-2 py-1 text-xs rounded border border-duck-teal/20 text-duck-teal/70 hover:bg-duck-teal/10 hover:text-duck-teal cursor-pointer"
|
||||||
|
onClick={() => onSplit(panel.id, 'horizontal')}
|
||||||
|
>
|
||||||
|
Split H
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-2 py-1 text-xs rounded border border-duck-teal/20 text-duck-teal/70 hover:bg-duck-teal/10 hover:text-duck-teal cursor-pointer"
|
||||||
|
onClick={() => onSplit(panel.id, 'vertical')}
|
||||||
|
>
|
||||||
|
Split V
|
||||||
|
</button>
|
||||||
|
{!isLastPanel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-2 py-1 text-xs rounded border border-red-400/40 text-red-400/70 hover:bg-red-400/10 hover:text-red-400 cursor-pointer"
|
||||||
|
onClick={() => onRemove(panel.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl"
|
||||||
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full [&>*]:!h-full [&>*]:!flex [&>*]:!flex-col [&>*]:!rounded-none [&>*]:!border-0 [&>*]:!shadow-none [&>*>*:last-child]:!flex-1 [&>*>*:last-child]:!min-h-0 [&>*>*:last-child]:!max-h-none [&>*>*:last-child]:!overflow-auto" style={{ ['--card-bg' as string]: 'transparent', ['--card-grid-color' as string]: 'transparent' }}>
|
||||||
|
<WidgetComponent panelId={panel.id} />
|
||||||
|
</div>
|
||||||
|
{editing && (
|
||||||
|
<LayoutEditor
|
||||||
|
panelId={panel.id}
|
||||||
|
hasWidget
|
||||||
|
isLastPanel={isLastPanel}
|
||||||
|
onSplit={onSplit}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onClearWidget={() => onSetWidget(panel.id, null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { WidgetRegistry } from './types';
|
||||||
|
|
||||||
|
type WidgetPickerProps = {
|
||||||
|
registry: WidgetRegistry;
|
||||||
|
onSelect: (widgetType: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WidgetPicker = ({ registry, onSelect }: WidgetPickerProps) => {
|
||||||
|
const entries = Object.entries(registry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||||
|
{entries.map(([key, entry]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className="flex flex-col items-center gap-1.5 rounded-lg border border-duck-teal/25 bg-white/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-white/90 transition-colors cursor-pointer"
|
||||||
|
onClick={() => onSelect(key)}
|
||||||
|
>
|
||||||
|
<entry.icon className="h-5 w-5" />
|
||||||
|
<span className="text-xs font-medium leading-tight text-center">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
type WorkspaceContextValue = {
|
||||||
|
workspaceId: string | null;
|
||||||
|
cwd: string;
|
||||||
|
editing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||||
|
workspaceId: null,
|
||||||
|
cwd: '~',
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||||
|
|
||||||
|
export const useWorkspace = () => useContext(WorkspaceContext);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Pencil, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
type WorkspaceHeaderProps = {
|
||||||
|
name: string;
|
||||||
|
editing: boolean;
|
||||||
|
onToggleEdit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceHeader = ({ name, editing, onToggleEdit }: WorkspaceHeaderProps) => (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-duck-teal/10">
|
||||||
|
<h2 className="text-sm font-bold text-duck-teal">{name}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ml-auto flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer ${
|
||||||
|
editing
|
||||||
|
? 'bg-duck-teal/15 text-duck-teal'
|
||||||
|
: 'text-duck-teal/60 hover:text-duck-teal hover:bg-duck-teal/10'
|
||||||
|
}`}
|
||||||
|
onClick={onToggleEdit}
|
||||||
|
>
|
||||||
|
{editing ? <Check size={14} /> : <Pencil size={14} />}
|
||||||
|
{editing ? 'Done' : 'Edit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
|
||||||
|
import type { LayoutNode, WidgetRegistry } from './types';
|
||||||
|
import { pruneEmptyPanels, countPanels } from './layout-utils';
|
||||||
|
import { PanelSlot } from './PanelSlot';
|
||||||
|
|
||||||
|
type WorkspaceRendererProps = {
|
||||||
|
layout: LayoutNode;
|
||||||
|
registry: WidgetRegistry;
|
||||||
|
editing: boolean;
|
||||||
|
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||||
|
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||||
|
onRemove: (panelId: string) => void;
|
||||||
|
onResized: (groupId: string, sizes: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceRenderer = ({
|
||||||
|
layout,
|
||||||
|
registry,
|
||||||
|
editing,
|
||||||
|
onSetWidget,
|
||||||
|
onSplit,
|
||||||
|
onRemove,
|
||||||
|
onResized,
|
||||||
|
}: WorkspaceRendererProps) => {
|
||||||
|
const displayLayout = editing ? layout : pruneEmptyPanels(layout);
|
||||||
|
if (!displayLayout) {
|
||||||
|
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">No widgets</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPanels = countPanels(layout);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full">
|
||||||
|
<LayoutNodeRenderer
|
||||||
|
node={displayLayout}
|
||||||
|
registry={registry}
|
||||||
|
editing={editing}
|
||||||
|
totalPanels={totalPanels}
|
||||||
|
onSetWidget={onSetWidget}
|
||||||
|
onSplit={onSplit}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onResized={onResized}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type LayoutNodeRendererProps = {
|
||||||
|
node: LayoutNode;
|
||||||
|
registry: WidgetRegistry;
|
||||||
|
editing: boolean;
|
||||||
|
totalPanels: number;
|
||||||
|
onSetWidget: (panelId: string, widgetType: string | null) => void;
|
||||||
|
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||||
|
onRemove: (panelId: string) => void;
|
||||||
|
onResized: (groupId: string, sizes: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LayoutNodeRenderer = ({
|
||||||
|
node,
|
||||||
|
registry,
|
||||||
|
editing,
|
||||||
|
totalPanels,
|
||||||
|
onSetWidget,
|
||||||
|
onSplit,
|
||||||
|
onRemove,
|
||||||
|
onResized,
|
||||||
|
}: LayoutNodeRendererProps) => {
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
|
|
||||||
|
const handleLayout = useCallback(
|
||||||
|
(sizes: number[]) => {
|
||||||
|
if (node.type !== 'group') return;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
onResized(node.id, sizes);
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
[node, onResized],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (node.type === 'panel') {
|
||||||
|
return (
|
||||||
|
<PanelSlot
|
||||||
|
panel={node}
|
||||||
|
registry={registry}
|
||||||
|
editing={editing}
|
||||||
|
isLastPanel={totalPanels <= 1}
|
||||||
|
onSetWidget={onSetWidget}
|
||||||
|
onSplit={onSplit}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResizablePanelGroup direction={node.direction} onLayout={handleLayout} className="h-full w-full">
|
||||||
|
{node.children.map((child, i) => (
|
||||||
|
<ChildEntry key={child.node.id} index={i} total={node.children.length}>
|
||||||
|
<ResizablePanel defaultSize={child.size} minSize={5}>
|
||||||
|
<div className="h-full w-full p-1">
|
||||||
|
<LayoutNodeRenderer
|
||||||
|
node={child.node}
|
||||||
|
registry={registry}
|
||||||
|
editing={editing}
|
||||||
|
totalPanels={totalPanels}
|
||||||
|
onSetWidget={onSetWidget}
|
||||||
|
onSplit={onSplit}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onResized={onResized}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ChildEntry>
|
||||||
|
))}
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChildEntryProps = {
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChildEntry = ({ index, total, children }: ChildEntryProps) => {
|
||||||
|
if (index === 0) return <>{children}</>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ResizableHandle className="bg-transparent after:bg-transparent" />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { LayoutNode, WorkspaceDefinition, WidgetRegistry } from './types';
|
||||||
|
import { splitPanel, removePanel, setWidget, updateSizes, countPanels } from './layout-utils';
|
||||||
|
import { WorkspaceProvider } from './WorkspaceContext';
|
||||||
|
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||||
|
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||||
|
|
||||||
|
type WorkspaceViewProps = {
|
||||||
|
workspace: WorkspaceDefinition | null;
|
||||||
|
name?: string;
|
||||||
|
layout: LayoutNode;
|
||||||
|
onLayoutChange: (layout: LayoutNode) => void;
|
||||||
|
registry: WidgetRegistry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const handleSetWidget = useCallback(
|
||||||
|
(panelId: string, widgetType: string | null) => {
|
||||||
|
onLayoutChange(setWidget(layout, panelId, widgetType));
|
||||||
|
},
|
||||||
|
[layout, onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSplit = useCallback(
|
||||||
|
(panelId: string, direction: 'horizontal' | 'vertical') => {
|
||||||
|
onLayoutChange(splitPanel(layout, panelId, direction));
|
||||||
|
},
|
||||||
|
[layout, onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(panelId: string) => {
|
||||||
|
if (countPanels(layout) <= 1) return;
|
||||||
|
onLayoutChange(removePanel(layout, panelId));
|
||||||
|
},
|
||||||
|
[layout, onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleResized = useCallback(
|
||||||
|
(groupId: string, sizes: number[]) => {
|
||||||
|
onLayoutChange(updateSizes(layout, groupId, sizes));
|
||||||
|
},
|
||||||
|
[layout, onLayoutChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayName = name ?? workspace?.name ?? 'Workspace';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceProvider value={{ workspaceId: workspace?.id ?? null, cwd: workspace?.cwd ?? '~', editing }}>
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
<WorkspaceHeader name={displayName} editing={editing} onToggleEdit={() => setEditing((v) => !v)} />
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<WorkspaceRenderer
|
||||||
|
layout={layout}
|
||||||
|
registry={registry}
|
||||||
|
editing={editing}
|
||||||
|
onSetWidget={handleSetWidget}
|
||||||
|
onSplit={handleSplit}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onResized={handleResized}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WorkspaceProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WidgetRegistry, WidgetRegistryEntry } from './types';
|
||||||
|
export { createDefaultLayout, splitPanel, removePanel, setWidget, updateSizes, pruneEmptyPanels, countPanels } from './layout-utils';
|
||||||
|
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||||
|
export { WorkspaceView } from './WorkspaceView';
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type { LayoutNode, LayoutPanel, LayoutGroup } from './types';
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
const uid = () => `p-${Date.now()}-${++counter}`;
|
||||||
|
|
||||||
|
export const createDefaultLayout = (): LayoutPanel => ({
|
||||||
|
type: 'panel',
|
||||||
|
id: uid(),
|
||||||
|
widgetType: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function splitPanel(root: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
|
||||||
|
return mapNode(root, (node, parent) => {
|
||||||
|
if (node.type !== 'panel' || node.id !== panelId) return node;
|
||||||
|
|
||||||
|
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
|
||||||
|
|
||||||
|
if (parent && parent.direction === direction) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group: LayoutGroup = {
|
||||||
|
type: 'group',
|
||||||
|
id: uid(),
|
||||||
|
direction,
|
||||||
|
children: [
|
||||||
|
{ node, size: 50 },
|
||||||
|
{ node: newPanel, size: 50 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNode(
|
||||||
|
node: LayoutNode,
|
||||||
|
fn: (node: LayoutNode, parent: LayoutGroup | null) => LayoutNode | null,
|
||||||
|
parent: LayoutGroup | null = null,
|
||||||
|
): LayoutNode {
|
||||||
|
const result = fn(node, parent);
|
||||||
|
|
||||||
|
if (result === null && parent !== null && node.type === 'panel') {
|
||||||
|
const newPanel: LayoutPanel = { type: 'panel', id: uid(), widgetType: null };
|
||||||
|
const idx = parent.children.findIndex((c) => c.node.id === node.id);
|
||||||
|
const newChildren = [
|
||||||
|
...parent.children.slice(0, idx + 1),
|
||||||
|
{ node: newPanel, size: 0 },
|
||||||
|
...parent.children.slice(idx + 1),
|
||||||
|
];
|
||||||
|
const size = 100 / newChildren.length;
|
||||||
|
parent.children = newChildren.map((c) => ({ ...c, size }));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result !== node) return result ?? node;
|
||||||
|
|
||||||
|
if (node.type === 'group') {
|
||||||
|
const newChildren = node.children.map((child) => ({
|
||||||
|
...child,
|
||||||
|
node: mapNode(child.node, fn, node),
|
||||||
|
}));
|
||||||
|
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
|
||||||
|
return { ...node, children: newChildren };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePanel(root: LayoutNode, panelId: string): LayoutNode {
|
||||||
|
if (root.type === 'panel') return root;
|
||||||
|
if (countPanels(root) <= 1) return root;
|
||||||
|
|
||||||
|
return removePanelInner(root);
|
||||||
|
|
||||||
|
function removePanelInner(node: LayoutNode): LayoutNode {
|
||||||
|
if (node.type === 'panel') return node;
|
||||||
|
|
||||||
|
const filtered = node.children.filter((c) => !(c.node.type === 'panel' && c.node.id === panelId));
|
||||||
|
|
||||||
|
if (filtered.length < node.children.length) {
|
||||||
|
if (filtered.length === 0) return createDefaultLayout();
|
||||||
|
if (filtered.length === 1) return filtered[0]!.node;
|
||||||
|
const total = filtered.reduce((sum, c) => sum + c.size, 0);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: filtered.map((c) => ({ ...c, size: (c.size / total) * 100 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newChildren = node.children.map((child) => ({
|
||||||
|
...child,
|
||||||
|
node: removePanelInner(child.node),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const unwrapped = newChildren.map((child) => {
|
||||||
|
if (child.node.type === 'group' && child.node.children.length === 1) {
|
||||||
|
return { ...child, node: child.node.children[0]!.node };
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...node, children: unwrapped };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWidget(root: LayoutNode, panelId: string, widgetType: string | null): LayoutNode {
|
||||||
|
if (root.type === 'panel') {
|
||||||
|
return root.id === panelId ? { ...root, widgetType } : root;
|
||||||
|
}
|
||||||
|
const newChildren = root.children.map((child) => ({
|
||||||
|
...child,
|
||||||
|
node: setWidget(child.node, panelId, widgetType),
|
||||||
|
}));
|
||||||
|
return { ...root, children: newChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSizes(root: LayoutNode, groupId: string, sizes: number[]): LayoutNode {
|
||||||
|
if (root.type === 'panel') return root;
|
||||||
|
if (root.id === groupId) {
|
||||||
|
return {
|
||||||
|
...root,
|
||||||
|
children: root.children.map((child, i) => ({ ...child, size: sizes[i] ?? child.size })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const newChildren = root.children.map((child) => ({
|
||||||
|
...child,
|
||||||
|
node: updateSizes(child.node, groupId, sizes),
|
||||||
|
}));
|
||||||
|
return { ...root, children: newChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneEmptyPanels(root: LayoutNode): LayoutNode | null {
|
||||||
|
if (root.type === 'panel') {
|
||||||
|
return root.widgetType ? root : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruned = root.children
|
||||||
|
.map((child) => {
|
||||||
|
const node = pruneEmptyPanels(child.node);
|
||||||
|
return node ? { ...child, node } : null;
|
||||||
|
})
|
||||||
|
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||||
|
|
||||||
|
if (pruned.length === 0) return null;
|
||||||
|
if (pruned.length === 1) return pruned[0]!.node;
|
||||||
|
|
||||||
|
const total = pruned.reduce((sum, c) => sum + c.size, 0);
|
||||||
|
return {
|
||||||
|
...root,
|
||||||
|
children: pruned.map((c) => ({ ...c, size: (c.size / total) * 100 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countPanels(node: LayoutNode): number {
|
||||||
|
if (node.type === 'panel') return 1;
|
||||||
|
return node.children.reduce((sum, child) => sum + countPanels(child.node), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
export type LayoutGroup = {
|
||||||
|
type: 'group';
|
||||||
|
id: string;
|
||||||
|
direction: 'horizontal' | 'vertical';
|
||||||
|
children: { node: LayoutNode; size: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutPanel = {
|
||||||
|
type: 'panel';
|
||||||
|
id: string;
|
||||||
|
widgetType: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutNode = LayoutGroup | LayoutPanel;
|
||||||
|
|
||||||
|
export type WorkspaceDefinition = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetRegistryEntry = {
|
||||||
|
name: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
component: ComponentType<{ panelId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetRegistry = Record<string, WidgetRegistryEntry>;
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./index.ts",
|
".": "./index.ts",
|
||||||
|
"./Workspace": "./Workspace/index.ts",
|
||||||
"./*": "./*.tsx",
|
"./*": "./*.tsx",
|
||||||
"./ui/*": "./ui/*.tsx"
|
"./ui/*": "./ui/*.tsx"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user