diff options
Diffstat (limited to 'apps/web/src/versions/components')
| -rw-r--r-- | apps/web/src/versions/components/version-name-dialog.tsx | 77 | ||||
| -rw-r--r-- | apps/web/src/versions/components/versions-popover.tsx | 320 |
2 files changed, 397 insertions, 0 deletions
diff --git a/apps/web/src/versions/components/version-name-dialog.tsx b/apps/web/src/versions/components/version-name-dialog.tsx new file mode 100644 index 00000000..f0514f5a --- /dev/null +++ b/apps/web/src/versions/components/version-name-dialog.tsx @@ -0,0 +1,77 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; +import { Label } from "@/components/ui/label"; + +export function VersionNameDialog({ + isOpen, + onOpenChange, + onConfirm, + mode, + initialName = "", +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (name: string) => void; + mode: "create" | "rename"; + initialName?: string; +}) { + const [name, setName] = useState(initialName); + + const handleOpenChange = (open: boolean) => { + if (open) { + setName(initialName); + } + onOpenChange(open); + }; + + const title = mode === "create" ? "Create checkpoint" : "Rename checkpoint"; + const confirmLabel = mode === "create" ? "Create" : "Rename"; + + return ( + <Dialog open={isOpen} onOpenChange={handleOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + </DialogHeader> + + <DialogBody className="gap-3"> + <Label>Name</Label> + <Input + value={name} + onChange={(e) => setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(name); + } + }} + placeholder="e.g. Final cut" + /> + </DialogBody> + + <DialogFooter> + <Button + variant="outline" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + }} + > + Cancel + </Button> + <Button onClick={() => onConfirm(name)}>{confirmLabel}</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/versions/components/versions-popover.tsx b/apps/web/src/versions/components/versions-popover.tsx new file mode 100644 index 00000000..b97e62da --- /dev/null +++ b/apps/web/src/versions/components/versions-popover.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { + Clock01Icon, + Delete02Icon, + Edit02Icon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useEditor } from "@/editor/use-editor"; +import { useVersionsStore } from "@/versions/versions-store"; +import { VersionNameDialog } from "@/versions/components/version-name-dialog"; +import type { ProjectVersion } from "@/versions/types"; + +function formatVersionDate({ date }: { date: Date }): string { + return new Date(date).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function getVersionTitle({ version }: { version: ProjectVersion }): string { + return version.kind === "named" && version.name + ? version.name + : "Auto-checkpoint"; +} + +export function VersionsButton() { + const editor = useEditor(); + const isPanelOpen = useVersionsStore((s) => s.isPanelOpen); + const setPanelOpen = useVersionsStore((s) => s.setPanelOpen); + const isCheckpointDialogOpen = useVersionsStore( + (s) => s.isCheckpointDialogOpen, + ); + const closeCheckpointDialog = useVersionsStore( + (s) => s.closeCheckpointDialog, + ); + const openCheckpointDialog = useVersionsStore((s) => s.openCheckpointDialog); + const projectId = useEditor((e) => e.project.getActiveOrNull()?.metadata.id); + const versions = useEditor((e) => + projectId ? e.versions.getVersions({ projectId }) : [], + ); + + const [restoreTarget, setRestoreTarget] = useState<ProjectVersion | null>( + null, + ); + const [renameTarget, setRenameTarget] = useState<ProjectVersion | null>(null); + const [deleteTarget, setDeleteTarget] = useState<ProjectVersion | null>(null); + + const handlePanelOpenChange = (open: boolean) => { + setPanelOpen({ open }); + if (open && projectId) { + editor.versions.listVersions({ projectId }).catch((error) => { + console.error("Failed to refresh versions:", error); + }); + } + }; + + const handleCreateCheckpoint = (name: string) => { + closeCheckpointDialog(); + editor.versions.createNamedCheckpoint({ name }).catch((error) => { + console.error("Failed to create checkpoint:", error); + }); + }; + + return ( + <> + <Popover open={isPanelOpen} onOpenChange={handlePanelOpenChange}> + <PopoverTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-8" + title="Versions" + > + <HugeiconsIcon icon={Clock01Icon} className="size-4" /> + </Button> + </PopoverTrigger> + <PopoverContent align="end" className="w-80 p-2"> + <div className="flex items-center justify-between px-2 pb-2"> + <span className="text-sm font-medium">Versions</span> + <Button + variant="outline" + size="sm" + onClick={openCheckpointDialog} + > + <HugeiconsIcon icon={PlusSignIcon} className="size-3.5" /> + Checkpoint + </Button> + </div> + {versions.length === 0 ? ( + <p className="text-muted-foreground px-2 py-6 text-center text-sm"> + No versions yet. Create a checkpoint to save a known-good state. + </p> + ) : ( + <ScrollArea className="max-h-80"> + <div className="flex flex-col gap-1"> + {versions.map((version) => ( + <VersionRow + key={version.id} + version={version} + onRestore={() => setRestoreTarget(version)} + onRename={ + version.kind === "named" + ? () => setRenameTarget(version) + : undefined + } + onDelete={ + version.kind === "named" + ? () => setDeleteTarget(version) + : undefined + } + /> + ))} + </div> + </ScrollArea> + )} + </PopoverContent> + </Popover> + + <VersionNameDialog + isOpen={isCheckpointDialogOpen} + onOpenChange={(open) => { + if (!open) closeCheckpointDialog(); + }} + onConfirm={handleCreateCheckpoint} + mode="create" + /> + + <VersionNameDialog + isOpen={renameTarget !== null} + onOpenChange={(open) => { + if (!open) setRenameTarget(null); + }} + onConfirm={(name) => { + const target = renameTarget; + setRenameTarget(null); + if (target) { + editor.versions + .renameVersion({ versionId: target.id, name }) + .catch((error) => { + console.error("Failed to rename checkpoint:", error); + }); + } + }} + mode="rename" + initialName={renameTarget?.name ?? ""} + /> + + <AlertDialog + open={restoreTarget !== null} + onOpenChange={(open) => { + if (!open) setRestoreTarget(null); + }} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + Restore {restoreTarget ? getVersionTitle({ version: restoreTarget }) : ""}? + </AlertDialogTitle> + <AlertDialogDescription> + The editor will jump to this version. Your current state is saved + as an auto-checkpoint first, so nothing is lost. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={() => { + const target = restoreTarget; + setRestoreTarget(null); + if (target) { + editor.versions + .restoreVersion({ versionId: target.id }) + .catch((error) => { + console.error("Failed to restore version:", error); + }); + } + }} + > + Restore + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + <AlertDialog + open={deleteTarget !== null} + onOpenChange={(open) => { + if (!open) setDeleteTarget(null); + }} + > + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle> + Delete {deleteTarget ? getVersionTitle({ version: deleteTarget }) : ""}? + </AlertDialogTitle> + <AlertDialogDescription> + This checkpoint is removed permanently. The current state of the + project is not affected. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={() => { + const target = deleteTarget; + setDeleteTarget(null); + if (target) { + editor.versions + .deleteVersion({ versionId: target.id }) + .catch((error) => { + console.error("Failed to delete checkpoint:", error); + }); + } + }} + > + Delete + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ); +} + +function VersionRow({ + version, + onRestore, + onRename, + onDelete, +}: { + version: ProjectVersion; + onRestore: () => void; + onRename?: () => void; + onDelete?: () => void; +}) { + return ( + <div className="hover:bg-accent flex items-center gap-1 rounded-sm p-1"> + <Button + variant="ghost" + className="h-auto flex-1 justify-start gap-2 px-1 py-1 text-left font-normal" + onClick={onRestore} + > + <div className="bg-muted relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-sm"> + {version.thumbnail ? ( + <Image + src={version.thumbnail} + alt="" + fill + sizes="48px" + className="object-cover" + /> + ) : ( + <HugeiconsIcon + icon={Clock01Icon} + className="text-muted-foreground size-4" + /> + )} + </div> + <div className="flex min-w-0 flex-col gap-0.5"> + <span className="truncate text-sm"> + {getVersionTitle({ version })} + </span> + <span className="text-muted-foreground text-xs"> + {formatVersionDate({ date: version.createdAt })} + </span> + </div> + <Badge + variant={version.kind === "named" ? "default" : "secondary"} + className="ml-auto shrink-0" + > + {version.kind === "named" ? "Named" : "Auto"} + </Badge> + </Button> + {onRename && ( + <Button + variant="ghost" + size="icon" + className="size-7 shrink-0" + title="Rename checkpoint" + onClick={onRename} + > + <HugeiconsIcon icon={Edit02Icon} className="size-3.5" /> + </Button> + )} + {onDelete && ( + <Button + variant="ghost" + size="icon" + className="size-7 shrink-0" + title="Delete checkpoint" + onClick={onDelete} + > + <HugeiconsIcon icon={Delete02Icon} className="size-3.5" /> + </Button> + )} + </div> + ); +} |
