Hyprland
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { LayoutGrid, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
} from './constants';
|
||||
|
||||
export const WorkspaceListApp = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [workspaces, setWorkspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
@@ -24,6 +27,8 @@ export const WorkspaceListApp = () => {
|
||||
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||
|
||||
const isWorkspacesPage = location.pathname === '/workspaces';
|
||||
|
||||
const handleEdit = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
|
||||
ev.stopPropagation();
|
||||
setSelected(null);
|
||||
@@ -39,7 +44,6 @@ export const WorkspaceListApp = () => {
|
||||
setWorkspaces((prev) => prev.filter((w) => w.id !== ws.id));
|
||||
if (selected === ws.id) setSelected(null);
|
||||
|
||||
// Clean up persisted layout
|
||||
const layoutKey = `ws-layout-${ws.id}`;
|
||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['USER_STATE']) ?? {};
|
||||
const { [layoutKey]: _, ...rest } = currentState;
|
||||
@@ -47,62 +51,77 @@ export const WorkspaceListApp = () => {
|
||||
client.patch('/user/state', { [layoutKey]: null }).catch(() => {});
|
||||
};
|
||||
|
||||
const handleClick = (ws: WorkspaceDefinition) => {
|
||||
if (isWorkspacesPage) {
|
||||
setSelected(ws.id);
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
} else {
|
||||
navigate(`/workspaces/${ws.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-3 pb-0">
|
||||
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
|
||||
<Link
|
||||
to="/workspaces"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Workspaces
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 px-3 pt-3">
|
||||
{workspaces.map((ws) => (
|
||||
<div
|
||||
key={ws.id}
|
||||
className={`flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium cursor-pointer group ${
|
||||
selected === ws.id
|
||||
isWorkspacesPage && selected === ws.id
|
||||
? 'bg-duck-teal/10 text-duck-teal'
|
||||
: 'text-duck-dark/60 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelected(ws.id);
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
onClick={() => handleClick(ws)}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left truncate">{ws.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleEdit(ev, ws)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity cursor-pointer"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleDelete(ev, ws)}
|
||||
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>
|
||||
{isWorkspacesPage && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleEdit(ev, ws)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-duck-teal transition-opacity cursor-pointer"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => handleDelete(ev, ws)}
|
||||
className="shrink-0 p-1 rounded text-duck-dark/20 md:opacity-0 md:group-hover:opacity-100 hover:text-red-500 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{workspaces.length === 0 && (
|
||||
<p className="text-xs text-duck-dark/40 px-3 py-4 text-center">No workspaces yet</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
}}
|
||||
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left">New workspace</span>
|
||||
</button>
|
||||
{isWorkspacesPage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
}}
|
||||
className="flex items-center gap-2.5 py-2 px-3 rounded-lg text-sm font-medium text-duck-dark/40 hover:bg-duck-dark/5 hover:text-duck-dark transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left">New workspace</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,10 @@ import { appRegistry } from './app-registry';
|
||||
|
||||
export const WorkspaceScreen = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [workspaces] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const [workspaces, , isLoaded] = useUserState<WorkspaceDefinition[]>('workspaces', []);
|
||||
const workspace = workspaces.find((ws) => ws.id === id);
|
||||
|
||||
if (!isLoaded) return null;
|
||||
if (!workspace) return <Navigate to="/workspaces" replace />;
|
||||
|
||||
return <WorkspaceScreenInner workspace={workspace} />;
|
||||
|
||||
@@ -6,7 +6,7 @@ type AppPickerProps = {
|
||||
};
|
||||
|
||||
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
||||
const entries = Object.entries(registry);
|
||||
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||
|
||||
@@ -1,88 +1,92 @@
|
||||
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
|
||||
import { Card } from '../Card';
|
||||
import { AppPicker } from './AppPicker';
|
||||
import { LayoutEditor } from './LayoutEditor';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
} from '../ui/context-menu';
|
||||
|
||||
type PanelSlotProps = {
|
||||
panel: LayoutPanel;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
interactive: boolean;
|
||||
isLastPanel: boolean;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
};
|
||||
|
||||
export const PanelSlot = ({ panel, registry, components, editing, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||
type PanelContextMenuProps = {
|
||||
panelId: string;
|
||||
hasApp: boolean;
|
||||
isLastPanel: boolean;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
onClearApp: () => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
|
||||
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
|
||||
{!isLastPanel && (
|
||||
<ContextMenuItem className="text-red-500 focus:text-red-500" onClick={() => onRemove(panelId)}>
|
||||
Remove panel
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||
const PanelComponent = components?.[panel.id];
|
||||
const entry = panel.appType ? registry[panel.appType] : null;
|
||||
const AppComponent = PanelComponent ?? entry?.component;
|
||||
|
||||
if (!editing && !AppComponent) {
|
||||
return (
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const contextMenu = interactive
|
||||
? (content: React.ReactNode) => (
|
||||
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||
{content}
|
||||
</PanelContextMenu>
|
||||
)
|
||||
: (content: React.ReactNode) => <>{content}</>;
|
||||
|
||||
if (!AppComponent) {
|
||||
return (
|
||||
if (!interactive) {
|
||||
return (
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return contextMenu(
|
||||
<div className="h-full w-full p-1">
|
||||
<Card className="relative h-full w-full flex flex-col items-center justify-center gap-3 p-4">
|
||||
<AppPicker registry={registry} onSelect={(type) => onSetApp(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>
|
||||
</Card>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (entry?.transparent) {
|
||||
return (
|
||||
return contextMenu(
|
||||
<div className="h-full w-full p-1">
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<AppComponent panelId={panel.id} />
|
||||
{editing && (
|
||||
<LayoutEditor
|
||||
panelId={panel.id}
|
||||
hasWidget
|
||||
isLastPanel={isLastPanel}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onClearApp={() => onSetApp(panel.id, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
return contextMenu(
|
||||
<div className="h-full w-full p-1">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
|
||||
@@ -91,17 +95,7 @@ export const PanelSlot = ({ panel, registry, components, editing, isLastPanel, o
|
||||
<Card className="h-full w-full overflow-hidden p-0 [&>*]:!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">
|
||||
<AppComponent panelId={panel.id} />
|
||||
</Card>
|
||||
{editing && (
|
||||
<LayoutEditor
|
||||
panelId={panel.id}
|
||||
hasWidget
|
||||
isLastPanel={isLastPanel}
|
||||
onSplit={onSplit}
|
||||
onRemove={onRemove}
|
||||
onClearApp={() => onSetApp(panel.id, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,13 +3,11 @@ 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;
|
||||
|
||||
@@ -24,12 +24,11 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', editing: false }}>
|
||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~' }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={false}
|
||||
onSetApp={noop}
|
||||
onSplit={noop}
|
||||
onRemove={noop}
|
||||
|
||||
@@ -8,7 +8,7 @@ type WorkspaceRendererProps = {
|
||||
layout: LayoutNode;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
interactive?: boolean;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
onRemove: (panelId: string) => void;
|
||||
@@ -19,7 +19,7 @@ export const WorkspaceRenderer = ({
|
||||
layout,
|
||||
registry,
|
||||
components,
|
||||
editing,
|
||||
interactive = false,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
onRemove,
|
||||
@@ -33,7 +33,7 @@ export const WorkspaceRenderer = ({
|
||||
node={layout}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
interactive={interactive}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
@@ -48,7 +48,7 @@ type LayoutNodeRendererProps = {
|
||||
node: LayoutNode;
|
||||
registry: AppRegistry;
|
||||
components?: PanelComponents;
|
||||
editing: boolean;
|
||||
interactive: boolean;
|
||||
totalPanels: number;
|
||||
onSetApp: (panelId: string, appType: string | null) => void;
|
||||
onSplit: (panelId: string, direction: 'horizontal' | 'vertical') => void;
|
||||
@@ -65,7 +65,7 @@ const LayoutNodeRenderer = ({
|
||||
node,
|
||||
registry,
|
||||
components,
|
||||
editing,
|
||||
interactive,
|
||||
totalPanels,
|
||||
onSetApp,
|
||||
onSplit,
|
||||
@@ -96,7 +96,7 @@ const LayoutNodeRenderer = ({
|
||||
panel={node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
interactive={interactive}
|
||||
isLastPanel={totalPanels <= 1}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
@@ -118,7 +118,7 @@ const LayoutNodeRenderer = ({
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
interactive={interactive}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
@@ -133,16 +133,16 @@ const LayoutNodeRenderer = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction={node.direction} onLayout={handleLayout} className="h-full w-full">
|
||||
<ResizablePanelGroup id={node.id} 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}>
|
||||
<ResizablePanel id={child.node.id} order={i} defaultSize={child.size} minSize={5}>
|
||||
<div className="h-full w-full">
|
||||
<LayoutNodeRenderer
|
||||
node={child.node}
|
||||
registry={registry}
|
||||
components={components}
|
||||
editing={editing}
|
||||
interactive={interactive}
|
||||
totalPanels={totalPanels}
|
||||
onSetApp={onSetApp}
|
||||
onSplit={onSplit}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, countPanels, hasAnyApp } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, 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: AppRegistry;
|
||||
};
|
||||
|
||||
export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||
const [editing, setEditing] = useState(() => !hasAnyApp(layout));
|
||||
|
||||
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||
const handleSetApp = useCallback(
|
||||
(panelId: string, appType: string | null) => {
|
||||
onLayoutChange(setApp(layout, panelId, appType));
|
||||
@@ -45,24 +41,17 @@ export const WorkspaceView = ({ workspace, name, layout, onLayoutChange, registr
|
||||
[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}
|
||||
onSetApp={handleSetApp}
|
||||
onSplit={handleSplit}
|
||||
onRemove={handleRemove}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceProvider value={{ workspaceId: workspace?.id ?? null, cwd: workspace?.cwd ?? '~' }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
interactive
|
||||
onSetApp={handleSetApp}
|
||||
onSplit={handleSplit}
|
||||
onRemove={handleRemove}
|
||||
onResized={handleResized}
|
||||
/>
|
||||
</WorkspaceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,15 +10,14 @@ export const createDefaultLayout = (): LayoutPanel => ({
|
||||
});
|
||||
|
||||
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;
|
||||
return splitInner(root, panelId, direction);
|
||||
}
|
||||
|
||||
function splitInner(node: LayoutNode, panelId: string, direction: 'horizontal' | 'vertical'): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
if (node.id !== panelId) return node;
|
||||
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
|
||||
|
||||
if (parent && parent.direction === direction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group: LayoutGroup = {
|
||||
type: 'group',
|
||||
id: uid(),
|
||||
@@ -29,41 +28,29 @@ export function splitPanel(root: LayoutNode, panelId: string, direction: 'horizo
|
||||
],
|
||||
};
|
||||
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') {
|
||||
// Check if the target panel is a direct child and the directions match — append as sibling
|
||||
const childIdx = node.children.findIndex((c) => c.node.type === 'panel' && c.node.id === panelId);
|
||||
if (childIdx !== -1 && node.direction === direction) {
|
||||
const newPanel: LayoutPanel = { type: 'panel', id: uid(), appType: null };
|
||||
const idx = parent.children.findIndex((c) => c.node.id === node.id);
|
||||
const newChildren = [
|
||||
...parent.children.slice(0, idx + 1),
|
||||
...node.children.slice(0, childIdx + 1),
|
||||
{ node: newPanel, size: 0 },
|
||||
...parent.children.slice(idx + 1),
|
||||
...node.children.slice(childIdx + 1),
|
||||
];
|
||||
const size = 100 / newChildren.length;
|
||||
parent.children = newChildren.map((c) => ({ ...c, size }));
|
||||
return node;
|
||||
return { ...node, children: newChildren.map((c) => ({ ...c, size })) };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
// Recurse into children
|
||||
const newChildren = node.children.map((child) => ({
|
||||
...child,
|
||||
node: splitInner(child.node, panelId, direction),
|
||||
}));
|
||||
if (newChildren.some((c, i) => c.node !== node.children[i]!.node)) {
|
||||
return { ...node, children: newChildren };
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export type AppRegistryEntry = {
|
||||
component: ComponentType<{ panelId: string }>;
|
||||
transparent?: boolean;
|
||||
fixedHeight?: number;
|
||||
widget?: boolean;
|
||||
};
|
||||
|
||||
export type AppRegistry = Record<string, AppRegistryEntry>;
|
||||
|
||||
@@ -11,13 +11,14 @@ type WidgetProps = ComponentPropsWithoutRef<'div'> & {
|
||||
title?: string;
|
||||
resizable?: boolean;
|
||||
collapsible?: boolean | { title: string; icon?: LucideIcon };
|
||||
minimizable?: boolean;
|
||||
moveable?: boolean;
|
||||
position?: Position;
|
||||
onPositionChange?: (pos: Position) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const Widget = ({ title, className, style, resizable, collapsible, moveable, position: controlledPosition, onPositionChange, onClose, children, ...props }: WidgetProps) => {
|
||||
export const Widget = ({ title, className, style, resizable, collapsible, minimizable = false, moveable, position: controlledPosition, onPositionChange, onClose, children, ...props }: WidgetProps) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [internalPosition, setInternalPosition] = useState<Position>({ x: 0, y: 0 });
|
||||
@@ -69,7 +70,7 @@ export const Widget = ({ title, className, style, resizable, collapsible, moveab
|
||||
const isCardPadding = target === card;
|
||||
const isHeader = !isCardPadding && target.closest('[data-widget-header]') && !target.closest('button');
|
||||
if (!isCardPadding && !isHeader) return;
|
||||
if (isHeader && ev.detail === 2) {
|
||||
if (minimizable && isHeader && ev.detail === 2) {
|
||||
toggleMinimized();
|
||||
return;
|
||||
}
|
||||
@@ -132,13 +133,15 @@ 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">
|
||||
{title && <span className="text-sm font-bold text-muted-foreground pointer-events-none">{title}</span>}
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleMinimized()}
|
||||
>
|
||||
{minimized ? <Plus size={14} /> : <Minus size={14} />}
|
||||
</button>
|
||||
{minimizable && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
onClick={() => toggleMinimized()}
|
||||
>
|
||||
{minimized ? <Plus size={14} /> : <Minus size={14} />}
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,9 +7,9 @@ import { DailyGoals } from './DailyGoals/index';
|
||||
import { QuickNotes } from './QuickNotes/index';
|
||||
|
||||
export const widgetRegistry: Record<string, AppRegistryEntry> = {
|
||||
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock /> },
|
||||
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather /> },
|
||||
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro /> },
|
||||
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals /> },
|
||||
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes /> },
|
||||
'clock': { name: 'Clock', icon: ClockIcon, component: () => <Clock />, widget: true },
|
||||
'weather': { name: 'Weather', icon: CloudSun, component: () => <Weather />, widget: true },
|
||||
'pomodoro': { name: 'Pomodoro', icon: Timer, component: () => <Pomodoro />, widget: true },
|
||||
'daily-goals': { name: 'Daily Goals', icon: Target, component: () => <DailyGoals />, widget: true },
|
||||
'quick-notes': { name: 'Quick Notes', icon: StickyNote, component: () => <QuickNotes />, widget: true },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user