no idea
This commit is contained in:
@@ -6,19 +6,19 @@ type AppPickerProps = {
|
||||
};
|
||||
|
||||
export const AppPicker = ({ registry, onSelect }: AppPickerProps) => {
|
||||
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget);
|
||||
const entries = Object.entries(registry).filter(([, entry]) => !entry.widget && entry.availableOnPanel !== false);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||
<div className="flex flex-wrap gap-1.5 max-w-md">
|
||||
{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-background/80 backdrop-blur-sm px-3 py-3 text-duck-teal hover:border-duck-teal/40 hover:bg-background/90 transition-colors cursor-pointer"
|
||||
className="flex items-center gap-1.5 rounded-full border border-duck-teal/25 bg-background/80 backdrop-blur-sm px-3 py-1.5 text-duck-teal hover:border-duck-teal/40 hover:bg-background/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>
|
||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium whitespace-nowrap">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { ArrowLeftRight, X } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowLeftRight, X, Minus } from 'lucide-react';
|
||||
import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
import { Card } from '../Card';
|
||||
@@ -37,12 +39,17 @@ const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelCompone
|
||||
typeof v === 'object' && v !== null && 'component' in v;
|
||||
|
||||
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
|
||||
const { swapSourceId, setSwapSourceId } = useWorkspace();
|
||||
const { swapSourceId, setSwapSourceId, maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||
const isMaximized = maximizedPanelId === panelId;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
|
||||
{isMaximized ? 'Restore' : 'Maximize'}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onSplit(panelId, 'horizontal')}>Split horizontal</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onSplit(panelId, 'vertical')}>Split vertical</ContextMenuItem>
|
||||
{hasApp && <ContextMenuItem onClick={onClearApp}>Clear app</ContextMenuItem>}
|
||||
@@ -101,6 +108,61 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId: string; isLastPanel: boolean; onRemove: (panelId: string) => void; onClearApp: () => void }) => {
|
||||
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
|
||||
const isMaximized = maximizedPanelId === panelId;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClearApp();
|
||||
}, [onClearApp]);
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
setMaximizedPanelId(null);
|
||||
}, [setMaximizedPanelId]);
|
||||
|
||||
const handleMaximize = useCallback(() => {
|
||||
setMaximizedPanelId(panelId);
|
||||
}, [panelId, setMaximizedPanelId]);
|
||||
|
||||
if (isMaximized) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestore}
|
||||
className="group/btn h-3 w-3 rounded-full bg-[#febc2e] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||
title="Restore"
|
||||
>
|
||||
<Minus className="h-2 w-2 text-[#5f4a00] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="group/btn h-3 w-3 rounded-full bg-[#ff5f57] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||
title={isLastPanel ? 'Clear app' : 'Close panel'}
|
||||
>
|
||||
<X className="h-2 w-2 text-[#4a0002] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMaximize}
|
||||
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
|
||||
title="Maximize"
|
||||
>
|
||||
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
|
||||
// const DragHandle = ({ panelId }: { panelId: string }) => {
|
||||
// const { setDragSourceId, dragSourceId } = useWorkspace();
|
||||
@@ -120,6 +182,9 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
|
||||
// };
|
||||
|
||||
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||
const { maximizedPanelId, transitioningPanelId } = useWorkspace();
|
||||
const isMaximized = maximizedPanelId === panel.id;
|
||||
|
||||
const rawPanelComponent = components?.[panel.id];
|
||||
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
|
||||
const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined);
|
||||
@@ -144,9 +209,6 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
||||
<>
|
||||
<SwapOverlay panelId={panel.id} />
|
||||
<SwapSourceIndicator panelId={panel.id} />
|
||||
{/* TODO: re-enable when drag-to-reposition is polished */}
|
||||
{/* <DragHandle panelId={panel.id} /> */}
|
||||
{/* <DragSourceDimmer panelId={panel.id} /> */}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
@@ -180,67 +242,93 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
||||
);
|
||||
}
|
||||
|
||||
// App with header — render header chrome + body
|
||||
if (HeaderComponent) {
|
||||
const headerBar = (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||
<HeaderComponent panelId={panel.id} />
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// All apps get a chrome header — custom HeaderComponent or default from registry icon+name
|
||||
const DefaultHeader = entry ? () => (
|
||||
<>
|
||||
<entry.icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">{entry.name}</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const body = (
|
||||
<div className="flex-1 min-h-0">
|
||||
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
||||
<AppComponent panelId={panel.id} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
|
||||
|
||||
const inner = ProviderComponent ? (
|
||||
<ProviderComponent panelId={panel.id}>
|
||||
{headerBar}
|
||||
{body}
|
||||
</ProviderComponent>
|
||||
) : (
|
||||
<>
|
||||
{headerBar}
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
const trafficLights = interactive ? (
|
||||
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
|
||||
) : null;
|
||||
|
||||
return contextMenu(
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||
const headerContent = (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||
{ResolvedHeader && <ResolvedHeader panelId={panel.id} />}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||
>
|
||||
{inner}
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{trafficLights}
|
||||
</div>
|
||||
);
|
||||
|
||||
const headerBar = interactive ? (
|
||||
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||
{headerContent}
|
||||
</PanelContextMenu>
|
||||
) : headerContent;
|
||||
|
||||
const body = (
|
||||
<div className="flex-1 min-h-0">
|
||||
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
||||
<AppComponent panelId={panel.id} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const inner = ProviderComponent ? (
|
||||
<ProviderComponent panelId={panel.id}>
|
||||
{headerBar}
|
||||
{body}
|
||||
</ProviderComponent>
|
||||
) : (
|
||||
<>
|
||||
{headerBar}
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMaximized) {
|
||||
return (
|
||||
<>
|
||||
{/* Placeholder to preserve layout space */}
|
||||
<div data-panel-id={panel.id} className="h-full w-full p-1">
|
||||
<div className="h-full w-full rounded-lg border border-dashed border-white/20" />
|
||||
</div>
|
||||
{overlays}
|
||||
</div>,
|
||||
{/* Maximized overlay — portalled to escape stacking contexts */}
|
||||
{createPortal(
|
||||
<div className="fixed inset-0 z-50 p-2">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col shadow-2xl"
|
||||
style={{ backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: no header
|
||||
return contextMenu(
|
||||
return (
|
||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||
<div
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
|
||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)', ...(transitioningPanelId === panel.id ? { viewTransitionName: `panel-${panel.id}` } : {}) } as React.CSSProperties}
|
||||
>
|
||||
<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>
|
||||
{inner}
|
||||
</div>
|
||||
{overlays}
|
||||
</div>,
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,9 @@ type WorkspaceContextValue = {
|
||||
dragSourceId: string | null;
|
||||
setDragSourceId: (id: string | null) => void;
|
||||
onMove: (sourceId: string, targetId: string, position: DropPosition) => void;
|
||||
maximizedPanelId: string | null;
|
||||
setMaximizedPanelId: (id: string | null) => void;
|
||||
transitioningPanelId: string | null;
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
@@ -24,6 +27,9 @@ const WorkspaceContext = createContext<WorkspaceContextValue>({
|
||||
dragSourceId: null,
|
||||
setDragSourceId: noop,
|
||||
onMove: noop,
|
||||
maximizedPanelId: null,
|
||||
setMaximizedPanelId: noop,
|
||||
transitioningPanelId: null,
|
||||
});
|
||||
|
||||
export const WorkspaceProvider = WorkspaceContext.Provider;
|
||||
|
||||
@@ -24,7 +24,7 @@ export const WorkspaceLayout = ({ layout, onLayoutChange, registry, components,
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop }}>
|
||||
<WorkspaceProvider value={{ workspaceId: workspaceId ?? null, cwd: cwd ?? '~', swapSourceId: null, setSwapSourceId: noop, onSwap: noop, dragSourceId: null, setDragSourceId: noop, onMove: noop, maximizedPanelId: null, setMaximizedPanelId: noop, transitioningPanelId: null }}>
|
||||
<WorkspaceRenderer
|
||||
layout={layout}
|
||||
registry={registry}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import type { LayoutNode, WorkspaceDefinition, AppRegistry } from './types';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
// TODO: re-enable when drag-to-reposition is polished
|
||||
// import { DragOverlay } from './DragOverlay';
|
||||
|
||||
type WorkspaceViewProps = {
|
||||
workspace: WorkspaceDefinition | null;
|
||||
@@ -17,6 +16,22 @@ type WorkspaceViewProps = {
|
||||
export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: WorkspaceViewProps) => {
|
||||
const [swapSourceId, setSwapSourceId] = useState<string | null>(null);
|
||||
const [dragSourceId, setDragSourceId] = useState<string | null>(null);
|
||||
const [maximizedPanelId, setMaximizedPanelId] = useState<string | null>(null);
|
||||
const [transitioningPanelId, setTransitioningPanelId] = useState<string | null>(null);
|
||||
|
||||
const setMaximizedAnimated = useCallback((id: string | null) => {
|
||||
const doc = document as Document & { startViewTransition?: (cb: () => void) => { finished: Promise<void> } };
|
||||
const panelId = maximizedPanelId ?? id;
|
||||
if (doc.startViewTransition && panelId) {
|
||||
setTransitioningPanelId(panelId);
|
||||
requestAnimationFrame(() => {
|
||||
const transition = doc.startViewTransition(() => flushSync(() => setMaximizedPanelId(id)));
|
||||
transition.finished.finally(() => setTransitioningPanelId(null));
|
||||
});
|
||||
} else {
|
||||
setMaximizedPanelId(id);
|
||||
}
|
||||
}, [maximizedPanelId]);
|
||||
|
||||
const handleSetApp = useCallback(
|
||||
(panelId: string, appType: string | null) => {
|
||||
@@ -72,16 +87,17 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!swapSourceId && !dragSourceId) return;
|
||||
if (!swapSourceId && !dragSourceId && !maximizedPanelId) return;
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') {
|
||||
setSwapSourceId(null);
|
||||
setDragSourceId(null);
|
||||
setMaximizedAnimated(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [swapSourceId, dragSourceId]);
|
||||
}, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
|
||||
|
||||
return (
|
||||
<WorkspaceProvider
|
||||
@@ -94,6 +110,9 @@ export const WorkspaceView = ({ workspace, layout, onLayoutChange, registry }: W
|
||||
dragSourceId,
|
||||
setDragSourceId: startDrag,
|
||||
onMove: handleMove,
|
||||
maximizedPanelId,
|
||||
setMaximizedPanelId: setMaximizedAnimated,
|
||||
transitioningPanelId,
|
||||
}}
|
||||
>
|
||||
<WorkspaceRenderer
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
||||
export type { DropPosition } from './layout-utils';
|
||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
|
||||
@@ -24,6 +24,20 @@ export type WorkspaceDefinition = {
|
||||
templateIdx?: number;
|
||||
};
|
||||
|
||||
export type ProjectType = 'landing-page' | 'website' | 'app';
|
||||
|
||||
export type ProjectDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
description?: string;
|
||||
projectType: ProjectType;
|
||||
hasBackend?: boolean;
|
||||
hasAuth?: boolean;
|
||||
gitRepo?: string;
|
||||
templateIdx?: number;
|
||||
};
|
||||
|
||||
export type AppRegistryEntry = {
|
||||
name: string;
|
||||
icon: LucideIcon;
|
||||
@@ -33,6 +47,7 @@ export type AppRegistryEntry = {
|
||||
transparent?: boolean;
|
||||
fixedHeight?: number;
|
||||
widget?: boolean;
|
||||
availableOnPanel?: boolean;
|
||||
};
|
||||
|
||||
export type AppRegistry = Record<string, AppRegistryEntry>;
|
||||
|
||||
Reference in New Issue
Block a user