443 lines
12 KiB
TypeScript
443 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactNode } from "react";
|
|
import {
|
|
IconEdit,
|
|
IconExternalLink,
|
|
IconLayoutGrid,
|
|
IconListDetails,
|
|
IconShare3,
|
|
IconSparkles,
|
|
} from "@tabler/icons-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { MobilePickerSheet } from "@/components/task/mobile/mobile-picker-sheet";
|
|
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
|
import { CanvasPage } from "@/components/plugins/canvas-page";
|
|
import { canvasHref, type Canvas } from "@/lib/api/domains/canvas-api";
|
|
import { CanvasMobileActionsButton } from "./canvas-host-actions";
|
|
import { canvasLockHelp, canvasPromotionHelp } from "./canvas-host-desktop-actions";
|
|
export { CanvasHostDialogs, CanvasMobileActionsButton } from "./canvas-host-actions";
|
|
export {
|
|
CanvasDesktopActions,
|
|
CanvasDesktopOverflowActions,
|
|
CanvasDesktopOverflowMenuItems,
|
|
CanvasDesktopPrimaryAction,
|
|
} from "./canvas-host-desktop-actions";
|
|
|
|
export type CanvasHostState =
|
|
| "loading_metadata"
|
|
| "pending_first_release"
|
|
| "pending_permission"
|
|
| "loading_runtime"
|
|
| "ready"
|
|
| "offline"
|
|
| "invalid_release"
|
|
| "unavailable"
|
|
| "archived";
|
|
|
|
const STATE_COPY: Record<CanvasHostState, { title: string; description: string }> = {
|
|
loading_metadata: {
|
|
title: "canvases:loadingCanvas",
|
|
description: "canvases:loadingCanvasDescription",
|
|
},
|
|
pending_first_release: {
|
|
title: "canvases:pendingFirstRelease",
|
|
description: "canvases:pendingFirstReleaseDescription",
|
|
},
|
|
pending_permission: {
|
|
title: "canvases:pendingPermission",
|
|
description: "canvases:pendingPermissionDescription",
|
|
},
|
|
loading_runtime: {
|
|
title: "canvases:loadingRuntime",
|
|
description: "canvases:loadingRuntimeDescription",
|
|
},
|
|
ready: { title: "canvases:ready", description: "canvases:readyDescription" },
|
|
offline: { title: "canvases:offline", description: "canvases:offlineDescription" },
|
|
invalid_release: {
|
|
title: "canvases:invalidRelease",
|
|
description: "canvases:invalidReleaseDescription",
|
|
},
|
|
unavailable: { title: "canvases:unavailable", description: "canvases:unavailableDescription" },
|
|
archived: { title: "canvases:archived", description: "canvases:archivedDescription" },
|
|
};
|
|
|
|
function MobileCanvasAction({
|
|
description,
|
|
children,
|
|
testId,
|
|
}: {
|
|
description: string;
|
|
children: ReactNode;
|
|
testId: string;
|
|
}) {
|
|
return (
|
|
<div className="space-y-0.5 px-1">
|
|
{children}
|
|
<p className="px-3 text-xs text-muted-foreground" data-testid={testId}>
|
|
{description}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CanvasHostBody({
|
|
canvasId,
|
|
title,
|
|
state,
|
|
runtimeUrl,
|
|
error,
|
|
onRuntimeReady,
|
|
onRuntimeError,
|
|
onRetry,
|
|
}: {
|
|
canvasId: string;
|
|
title: string;
|
|
state: CanvasHostState;
|
|
runtimeUrl: string | null;
|
|
error: string | null;
|
|
onRuntimeReady: () => void;
|
|
onRuntimeError: () => void;
|
|
onRetry: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div
|
|
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
|
|
data-testid="canvas-host-route"
|
|
>
|
|
{(state === "loading_runtime" || state === "ready") && runtimeUrl ? (
|
|
<>
|
|
{state === "ready" && (
|
|
<span
|
|
className="sr-only"
|
|
role="status"
|
|
aria-live="polite"
|
|
data-testid="canvas-host-state"
|
|
>
|
|
<span data-testid="canvas-host-ready-announcement">{t(STATE_COPY.ready.title)}</span>
|
|
</span>
|
|
)}
|
|
<CanvasPage
|
|
key={`${canvasId}:${runtimeUrl}`}
|
|
runtimeUrl={runtimeUrl}
|
|
title={title}
|
|
onLoad={onRuntimeReady}
|
|
onError={onRuntimeError}
|
|
/>
|
|
</>
|
|
) : (
|
|
<CanvasHostStatePanel state={state} error={error} onRetry={onRetry} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CanvasHostHeader({
|
|
title,
|
|
isMobile,
|
|
menuOpen,
|
|
onOpenActions,
|
|
actions,
|
|
renameAction,
|
|
overflowActions,
|
|
}: {
|
|
title: string;
|
|
isMobile: boolean;
|
|
menuOpen: boolean;
|
|
onOpenActions: () => void;
|
|
actions?: ReactNode;
|
|
renameAction?: ReactNode;
|
|
overflowActions?: ReactNode;
|
|
}) {
|
|
return (
|
|
<PanelHeaderBarSplit
|
|
data-testid="canvas-host-header"
|
|
left={
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
<span className="truncate text-sm font-medium">{title}</span>
|
|
{renameAction}
|
|
</div>
|
|
}
|
|
right={
|
|
<>
|
|
{actions}
|
|
{isMobile && (
|
|
<CanvasMobileActionsButton menuOpen={menuOpen} onOpenActions={onOpenActions} />
|
|
)}
|
|
</>
|
|
}
|
|
overflow={!isMobile ? overflowActions : undefined}
|
|
overflowAt={520}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function CanvasHostStatePanel({
|
|
state,
|
|
error,
|
|
onRetry,
|
|
}: {
|
|
state: CanvasHostState;
|
|
error: string | null;
|
|
onRetry: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const copy = STATE_COPY[state];
|
|
return (
|
|
<div
|
|
className="flex min-h-0 flex-1 items-center justify-center p-6 text-center"
|
|
data-testid="canvas-host-state-panel"
|
|
>
|
|
<div className="max-w-md space-y-3">
|
|
<h2 className="text-lg font-semibold" data-testid="canvas-host-state">
|
|
{t(copy.title)}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">{error || t(copy.description)}</p>
|
|
{state !== "loading_metadata" && state !== "loading_runtime" && (
|
|
<Button
|
|
variant="outline"
|
|
className={controlSizingClassName("standard", "cursor-pointer")}
|
|
onClick={onRetry}
|
|
>
|
|
{t("canvases:retry")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MobileCanvasPicker({
|
|
canvases,
|
|
canvas,
|
|
onSelectCanvas,
|
|
t,
|
|
}: {
|
|
canvases: Canvas[];
|
|
canvas: Canvas | null;
|
|
onSelectCanvas: (canvas: Canvas) => void;
|
|
t: (key: string) => string;
|
|
}) {
|
|
return (
|
|
<div className="mb-2 border-b pb-2" data-testid="canvas-mobile-picker">
|
|
<p className="px-3 pb-1 text-xs font-medium text-muted-foreground">
|
|
{t("canvases:canvases")}
|
|
</p>
|
|
{canvases.map((candidate) => (
|
|
<Button
|
|
key={candidate.id}
|
|
variant="ghost"
|
|
className="min-h-11 w-full justify-start cursor-pointer"
|
|
disabled={candidate.id === canvas?.id}
|
|
onClick={() => onSelectCanvas(candidate)}
|
|
data-testid={`canvas-mobile-picker-item-${candidate.id}`}
|
|
>
|
|
<IconLayoutGrid className="mr-2 h-4 w-4" />
|
|
<span className="truncate">{candidate.title}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MobileCanvasEditAction({
|
|
canvas,
|
|
editing,
|
|
onEdit,
|
|
t,
|
|
}: {
|
|
canvas: Canvas;
|
|
editing: boolean;
|
|
onEdit: () => void;
|
|
t: (key: string) => string;
|
|
}) {
|
|
const lifecycleLocked = canvas.status === "archived" || canvas.status === "disabled";
|
|
return (
|
|
<MobileCanvasAction
|
|
description={lifecycleLocked ? canvasLockHelp(canvas, t) : t("canvases:editCanvasHelp")}
|
|
testId="canvas-action-edit-help"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
className="min-h-11 w-full justify-start cursor-pointer"
|
|
disabled={editing || lifecycleLocked}
|
|
onClick={onEdit}
|
|
>
|
|
<IconEdit className="mr-2 h-4 w-4" />
|
|
{t("canvases:editCanvas")}
|
|
</Button>
|
|
</MobileCanvasAction>
|
|
);
|
|
}
|
|
|
|
function MobileCanvasReleasesAction({
|
|
onReleases,
|
|
t,
|
|
}: {
|
|
onReleases: () => void;
|
|
t: (key: string) => string;
|
|
}) {
|
|
return (
|
|
<MobileCanvasAction
|
|
description={t("canvases:releasesAndPermissionsHelp")}
|
|
testId="canvas-action-releases-help"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
className="min-h-11 w-full justify-start cursor-pointer"
|
|
onClick={onReleases}
|
|
>
|
|
<IconListDetails className="mr-2 h-4 w-4" />
|
|
{t("canvases:releasesAndPermissions")}
|
|
</Button>
|
|
</MobileCanvasAction>
|
|
);
|
|
}
|
|
|
|
function MobileCanvasPromoteAction({
|
|
canvas,
|
|
lifecycleLocked,
|
|
onPromote,
|
|
t,
|
|
}: {
|
|
canvas: Canvas;
|
|
lifecycleLocked: boolean;
|
|
onPromote: () => void;
|
|
t: (key: string) => string;
|
|
}) {
|
|
const promoteDisabled = lifecycleLocked || canvas.active_release_status !== "valid";
|
|
return (
|
|
<MobileCanvasAction
|
|
description={canvasPromotionHelp(canvas, t)}
|
|
testId="canvas-action-promote-help"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
className="min-h-11 w-full justify-start cursor-pointer"
|
|
disabled={promoteDisabled}
|
|
onClick={onPromote}
|
|
>
|
|
<IconSparkles className="mr-2 h-4 w-4" />
|
|
{t("canvases:promoteCanvas")}
|
|
</Button>
|
|
</MobileCanvasAction>
|
|
);
|
|
}
|
|
|
|
function MobileCanvasShareAction({
|
|
onShare,
|
|
t,
|
|
}: {
|
|
onShare: () => void;
|
|
t: (key: string) => string;
|
|
}) {
|
|
return (
|
|
<MobileCanvasAction
|
|
description={t("canvases:shareCanvasDescription")}
|
|
testId="canvas-action-share-help"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
className="min-h-11 w-full justify-start cursor-pointer"
|
|
onClick={onShare}
|
|
>
|
|
<IconShare3 className="mr-2 h-4 w-4" />
|
|
{t("canvases:shareCanvas")}
|
|
</Button>
|
|
</MobileCanvasAction>
|
|
);
|
|
}
|
|
|
|
function MobileCanvasNewTabAction({ canvas, t }: { canvas: Canvas; t: (key: string) => string }) {
|
|
return (
|
|
<MobileCanvasAction
|
|
description={t("canvases:openInNewTabHelp")}
|
|
testId="canvas-action-new-tab-help"
|
|
>
|
|
<Button variant="ghost" className="min-h-11 w-full justify-start cursor-pointer" asChild>
|
|
<a href={canvasHref(canvas.id)} target="_blank" rel="noreferrer">
|
|
<IconExternalLink className="mr-2 h-4 w-4" />
|
|
{t("canvases:openInNewTab")}
|
|
</a>
|
|
</Button>
|
|
</MobileCanvasAction>
|
|
);
|
|
}
|
|
|
|
export function MobileCanvasActions({
|
|
canvas,
|
|
canvases,
|
|
open,
|
|
onOpenChange,
|
|
onEdit,
|
|
onPromote,
|
|
onReleases,
|
|
onShare,
|
|
onRename,
|
|
onSelectCanvas,
|
|
editing,
|
|
}: {
|
|
canvas: Canvas | null;
|
|
canvases: Canvas[];
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onEdit: () => void;
|
|
onPromote: () => void;
|
|
onReleases: () => void;
|
|
onShare: () => void;
|
|
onRename: () => void;
|
|
onSelectCanvas: (canvas: Canvas) => void;
|
|
editing: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const lifecycleLocked = canvas?.status === "archived" || canvas?.status === "disabled";
|
|
return (
|
|
<MobilePickerSheet
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
title={t("canvases:canvasActions")}
|
|
description={canvas?.title}
|
|
contentTestId="canvas-mobile-actions-sheet"
|
|
>
|
|
<div className="flex flex-col gap-1 pb-2">
|
|
{canvases.length > 0 && (
|
|
<MobileCanvasPicker
|
|
canvases={canvases}
|
|
canvas={canvas}
|
|
onSelectCanvas={onSelectCanvas}
|
|
t={t}
|
|
/>
|
|
)}
|
|
{canvas && (
|
|
<Button
|
|
variant="ghost"
|
|
className="min-h-11 w-full justify-start"
|
|
onClick={onRename}
|
|
data-testid="canvas-mobile-rename"
|
|
>
|
|
<IconEdit className="mr-2 size-4" />
|
|
{t("canvases:renameCanvas")}
|
|
</Button>
|
|
)}
|
|
{canvas?.scope_kind === "workspace" && (
|
|
<MobileCanvasEditAction canvas={canvas} editing={editing} onEdit={onEdit} t={t} />
|
|
)}
|
|
<MobileCanvasReleasesAction onReleases={onReleases} t={t} />
|
|
{canvas && <MobileCanvasShareAction onShare={onShare} t={t} />}
|
|
{canvas?.scope_kind === "task" && (
|
|
<MobileCanvasPromoteAction
|
|
canvas={canvas}
|
|
lifecycleLocked={Boolean(lifecycleLocked)}
|
|
onPromote={onPromote}
|
|
t={t}
|
|
/>
|
|
)}
|
|
{canvas && <MobileCanvasNewTabAction canvas={canvas} t={t} />}
|
|
</div>
|
|
</MobilePickerSheet>
|
|
);
|
|
}
|