This commit is contained in:
2026-02-23 22:52:27 +00:00
parent 8fb96c7cf8
commit 2126f3912e
35 changed files with 1126 additions and 137 deletions
@@ -27,6 +27,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
useEffect(() => {
const v = videoRef.current;
if (!v) return;
let blobUrl: string | null = null;
const onLoaded = () => {
setDuration(v.duration);
setLoaded(true);
@@ -35,13 +36,33 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onEnded = () => setPlaying(false);
let fetching = false;
let fetchDone = false;
const onError = () => {
if (fallbackSrc && v.src !== fallbackSrc) {
v.src = fallbackSrc;
v.load();
} else {
if (fetching) return;
if (fetchDone) {
setError(true);
return;
}
fetching = true;
const fetchUrl = fallbackSrc || src;
fetch(fetchUrl)
.then((res) => {
if (!res.ok) throw new Error();
return res.blob();
})
.then((blob) => {
fetching = false;
fetchDone = true;
blobUrl = URL.createObjectURL(blob);
v.src = blobUrl;
v.load();
})
.catch(() => {
fetching = false;
fetchDone = true;
setError(true);
});
};
v.addEventListener('loadedmetadata', onLoaded);
v.addEventListener('timeupdate', onTime);
@@ -56,6 +77,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
v.removeEventListener('pause', onPause);
v.removeEventListener('ended', onEnded);
v.removeEventListener('error', onError);
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, []);
@@ -125,7 +147,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
if (playing) setShowControls(false);
}}
>
<video ref={videoRef} src={src} preload="metadata" className="max-w-full max-h-full" onClick={togglePlay} />
<video ref={videoRef} src={src} preload="metadata" playsInline className="max-w-full max-h-full" onClick={togglePlay} />
{loaded && !playing && (
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
@@ -19,6 +19,7 @@ type PanelSlotProps = {
registry: AppRegistry;
components?: PanelComponents;
interactive: boolean;
locked: boolean;
noHeader: boolean;
isLastPanel: boolean;
onSetApp: (panelId: string, appType: string | null) => void;
@@ -164,6 +165,46 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
);
};
const MaximizeButton = ({ panelId }: { panelId: string }) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
type="button"
onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? (
<Minus className="h-2 w-2 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
) : (
<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>
);
};
const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: React.ReactNode }) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
{isMaximized ? 'Restore' : 'Maximize'}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
// TODO: drag-to-reposition needs work (visual feedback, edge cases)
// const DragHandle = ({ panelId }: { panelId: string }) => {
// const { setDragSourceId, dragSourceId } = useWorkspace();
@@ -182,7 +223,7 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
// return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />;
// };
export const PanelSlot = ({ panel, registry, components, interactive, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
export const PanelSlot = ({ panel, registry, components, interactive, locked, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id;
@@ -199,14 +240,18 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
const onClose = panelEntry?.onClose;
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>
)
? locked
? (content: React.ReactNode) => (
<MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
)
: (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}</>;
const overlays = interactive ? (
const overlays = interactive && !locked ? (
<>
<SwapOverlay panelId={panel.id} />
<SwapSourceIndicator panelId={panel.id} />
@@ -214,7 +259,7 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
) : null;
if (!AppComponent) {
if (!interactive) {
if (!interactive || locked) {
return (
<div className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
@@ -254,7 +299,11 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
const ResolvedHeader = HeaderComponent ?? DefaultHeader;
const trafficLights = interactive && !isMobile ? (
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
locked ? (
<MaximizeButton panelId={panel.id} />
) : (
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
)
) : null;
const mobileBackButton = isMobile && onMobileBack ? (
@@ -285,9 +334,13 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
);
const headerBar = interactive ? (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
{headerContent}
</PanelContextMenu>
locked ? (
<MaximizeContextMenu panelId={panel.id}>{headerContent}</MaximizeContextMenu>
) : (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
{headerContent}
</PanelContextMenu>
)
) : headerContent;
const body = (
@@ -9,6 +9,7 @@ type WorkspaceRendererProps = {
registry: AppRegistry;
components?: PanelComponents;
interactive?: boolean;
locked?: boolean;
noHeader?: boolean;
isMobile?: boolean;
mobilePanelId?: string;
@@ -23,6 +24,7 @@ export const WorkspaceRenderer = ({
registry,
components,
interactive = false,
locked = false,
noHeader = false,
isMobile = false,
mobilePanelId,
@@ -40,6 +42,7 @@ export const WorkspaceRenderer = ({
registry={registry}
components={components}
interactive={interactive}
locked={locked}
noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
@@ -58,6 +61,7 @@ type LayoutNodeRendererProps = {
registry: AppRegistry;
components?: PanelComponents;
interactive: boolean;
locked: boolean;
noHeader: boolean;
isMobile: boolean;
mobilePanelId?: string;
@@ -91,6 +95,7 @@ const LayoutNodeRenderer = ({
registry,
components,
interactive,
locked,
noHeader,
isMobile,
mobilePanelId,
@@ -125,6 +130,7 @@ const LayoutNodeRenderer = ({
registry={registry}
components={components}
interactive={interactive}
locked={locked}
noHeader={noHeader}
isLastPanel={totalPanels <= 1}
onSetApp={onSetApp}
@@ -144,6 +150,7 @@ const LayoutNodeRenderer = ({
registry={registry}
components={components}
interactive={interactive}
locked={locked}
noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
@@ -171,6 +178,7 @@ const LayoutNodeRenderer = ({
registry={registry}
components={components}
interactive={interactive}
locked={locked}
noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
@@ -198,6 +206,7 @@ const LayoutNodeRenderer = ({
registry={registry}
components={components}
interactive={interactive}
locked={locked}
noHeader={noHeader}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
@@ -12,6 +12,7 @@ import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
type WorkspaceViewProps = {
workspace: WorkspaceState;
locked?: boolean;
cwd?: string;
root?: string;
initialFilePath?: string;
@@ -24,7 +25,7 @@ type WorkspaceViewProps = {
const noop = () => {};
export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
const { registry } = useAppRegistry();
const isMobile = useIsMobile();
@@ -178,11 +179,12 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def
registry={registry}
components={components}
interactive
locked={locked}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
onSetApp={handleSetApp}
onSplit={handleSplit}
onRemove={handleRemove}
onSetApp={locked ? noop : handleSetApp}
onSplit={locked ? noop : handleSplit}
onRemove={locked ? noop : handleRemove}
onResized={handleResized}
/>
</ResizablePanel>
+7
View File
@@ -15,6 +15,7 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding },
});
export const useSettings = () => {
@@ -72,6 +73,9 @@ export type UserSettings = {
default: string;
translateTo: string;
};
onboarding: {
complete: boolean;
};
};
export type UserState = Record<string, unknown>;
@@ -103,4 +107,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
default: 'en',
translateTo: 'en',
},
onboarding: {
complete: false,
},
};