diff options
Diffstat (limited to 'apps/web/src/components/editor/dialogs')
5 files changed, 512 insertions, 0 deletions
diff --git a/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx b/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx new file mode 100644 index 00000000..0086384e --- /dev/null +++ b/apps/web/src/components/editor/dialogs/delete-project-dialog.tsx @@ -0,0 +1,84 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +export function DeleteProjectDialog({ + isOpen, + onOpenChange, + onConfirm, + projectNames, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + projectNames: string[]; +}) { + const count = projectNames.length; + const isSingle = count === 1; + const singleName = isSingle ? projectNames[0] : null; + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent + onOpenAutoFocus={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + > + <DialogHeader> + <DialogTitle> + {singleName ? ( + <> + {"Delete '"} + <span className="inline-block max-w-[300px] truncate align-bottom"> + {singleName} + </span> + {"'?"} + </> + ) : ( + `Delete ${count} projects?` + )} + </DialogTitle> + </DialogHeader> + <DialogBody> + <Alert variant="destructive"> + <AlertTitle>Warning</AlertTitle> + <AlertDescription> + This will permanently delete{" "} + {singleName ? `"${singleName}"` : `${count} projects`} and all + associated files. + </AlertDescription> + </Alert> + <div className="flex flex-col gap-3"> + <Label className="text-xs font-semibold text-slate-500"> + Type "DELETE" to confirm + </Label> + <Input + type="text" + placeholder="DELETE" + size="lg" + variant="destructive" + /> + </div> + </DialogBody> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button variant="destructive" onClick={onConfirm}> + Delete project + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/editor/dialogs/migration-dialog.tsx b/apps/web/src/components/editor/dialogs/migration-dialog.tsx new file mode 100644 index 00000000..4c9550c2 --- /dev/null +++ b/apps/web/src/components/editor/dialogs/migration-dialog.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useEditor } from "@/hooks/use-editor"; +import { Loader2 } from "lucide-react"; + +export function MigrationDialog() { + const editor = useEditor(); + const migrationState = editor.project.getMigrationState(); + + if (!migrationState.isMigrating) return null; + + const title = migrationState.projectName + ? "Updating project" + : "Updating projects"; + const description = migrationState.projectName + ? `Upgrading "${migrationState.projectName}" from v${migrationState.fromVersion} to v${migrationState.toVersion}` + : `Upgrading projects from v${migrationState.fromVersion} to v${migrationState.toVersion}`; + + return ( + <Dialog open={true}> + <DialogContent + className="sm:max-w-md" + onPointerDownOutside={(event) => event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + > + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + + <div className="flex items-center justify-center py-4"> + <Loader2 className="text-muted-foreground size-8 animate-spin" /> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx new file mode 100644 index 00000000..7b736b23 --- /dev/null +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -0,0 +1,83 @@ +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { TProjectMetadata } from "@/types/project"; +import { formatDate } from "@/utils/date"; +import { formatTimeCode } from "@/lib/time"; +import { Button } from "@/components/ui/button"; + +function InfoRow({ + label, + value, +}: { + label: string; + value: string | React.ReactNode; +}) { + return ( + <div className="flex justify-between items-center py-0 last:pb-0"> + <span className="text-muted-foreground text-sm">{label}</span> + <span className="text-sm font-medium">{value}</span> + </div> + ); +} + +export function ProjectInfoDialog({ + isOpen, + onOpenChange, + project, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + project: TProjectMetadata; +}) { + const durationFormatted = + project.duration > 0 + ? formatTimeCode({ + timeInSeconds: project.duration, + format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS", + }) + : "0:00"; + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}> + <DialogHeader> + <DialogTitle className="truncate max-w-[350px]"> + {project.name} + </DialogTitle> + </DialogHeader> + + <DialogBody className="flex flex-col"> + <InfoRow label="Duration" value={durationFormatted} /> + <InfoRow + label="Created" + value={formatDate({ date: project.createdAt })} + /> + <InfoRow + label="Modified" + value={formatDate({ date: project.updatedAt })} + /> + <InfoRow + label="Project ID" + value={ + <code className="text-xs bg-muted px-1.5 py-0.5 rounded"> + {project.id.slice(0, 8)} + </code> + } + /> + </DialogBody> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Close + </Button> + <Button onClick={() => onOpenChange(false)}>Done</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx b/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx new file mode 100644 index 00000000..73024d71 --- /dev/null +++ b/apps/web/src/components/editor/dialogs/rename-project-dialog.tsx @@ -0,0 +1,72 @@ +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 RenameProjectDialog({ + isOpen, + onOpenChange, + onConfirm, + projectName, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (newName: string) => void; + projectName: string; +}) { + const [name, setName] = useState(projectName); + + const handleOpenChange = (open: boolean) => { + if (open) { + setName(projectName); + } + onOpenChange(open); + }; + + return ( + <Dialog open={isOpen} onOpenChange={handleOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>Rename project</DialogTitle> + </DialogHeader> + + <DialogBody className="gap-3"> + <Label>New name</Label> + <Input + value={name} + onChange={(e) => setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(name); + } + }} + placeholder="Enter a new name" + /> + </DialogBody> + + <DialogFooter> + <Button + variant="outline" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + }} + > + Cancel + </Button> + <Button onClick={() => onConfirm(name)}>Rename</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx new file mode 100644 index 00000000..565fe705 --- /dev/null +++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + type KeyboardShortcut, + useKeyboardShortcutsHelp, +} from "@/hooks/use-keyboard-shortcuts-help"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function ShortcutsDialog({ + isOpen, + onOpenChange, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [recordingShortcut, setRecordingShortcut] = + useState<KeyboardShortcut | null>(null); + + const { + updateKeybinding, + removeKeybinding, + getKeybindingString, + validateKeybinding, + getKeybindingsForAction, + setIsRecording, + resetToDefaults, + isRecording, + } = useKeybindingsStore(); + + const { shortcuts } = useKeyboardShortcutsHelp(); + + const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + + useEffect(() => { + if (!isRecording || !recordingShortcut) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const keyString = getKeybindingString(e); + if (keyString) { + const conflict = validateKeybinding( + keyString, + recordingShortcut.action, + ); + if (conflict) { + toast.error( + `Key "${keyString}" is already bound to "${conflict.existingAction}"`, + ); + setRecordingShortcut(null); + return; + } + + const oldKeys = getKeybindingsForAction(recordingShortcut.action); + for (const key of oldKeys) { + removeKeybinding(key); + } + + updateKeybinding(keyString, recordingShortcut.action); + + setIsRecording(false); + setRecordingShortcut(null); + } + }; + + const handleClickOutside = () => { + setRecordingShortcut(null); + setIsRecording(false); + }; + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("click", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("click", handleClickOutside); + }; + }, [ + recordingShortcut, + getKeybindingString, + updateKeybinding, + removeKeybinding, + validateKeybinding, + getKeybindingsForAction, + setIsRecording, + isRecording, + ]); + + const handleStartRecording = (shortcut: KeyboardShortcut) => { + setRecordingShortcut(shortcut); + setIsRecording(true); + }; + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0"> + <DialogHeader> + <DialogTitle>Keyboard shortcuts</DialogTitle> + </DialogHeader> + + <DialogBody className="scrollbar-thin flex-grow overflow-y-auto"> + <div className="flex flex-col gap-6"> + {categories.map((category) => ( + <div key={category} className="flex flex-col gap-1"> + <h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> + {category} + </h3> + <div className="flex flex-col gap-1"> + {shortcuts + .filter((shortcut) => shortcut.category === category) + .map((shortcut) => ( + <ShortcutItem + key={shortcut.action} + shortcut={shortcut} + isRecording={ + shortcut.action === recordingShortcut?.action + } + onStartRecording={() => handleStartRecording(shortcut)} + /> + ))} + </div> + </div> + ))} + </div> + </DialogBody> + <DialogFooter> + <Button variant="destructive" onClick={resetToDefaults}> + Reset to default + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +function ShortcutItem({ + shortcut, + isRecording, + onStartRecording, +}: { + shortcut: KeyboardShortcut; + isRecording: boolean; + onStartRecording: (params: { shortcut: KeyboardShortcut }) => void; +}) { + const displayKeys = shortcut.keys.filter((key: string) => { + if ( + key.includes("Cmd") && + shortcut.keys.includes(key.replace("Cmd", "Ctrl")) + ) + return false; + + return true; + }); + + return ( + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + {shortcut.icon && ( + <div className="text-muted-foreground">{shortcut.icon}</div> + )} + <span className="text-sm">{shortcut.description}</span> + </div> + <div className="flex items-center gap-2"> + {displayKeys.map((key: string, index: number) => ( + <div key={key} className="flex items-center gap-2"> + <div className="flex items-center gap-1"> + {key.split("+").map((keyPart: string, partIndex: number) => { + const keyId = `${shortcut.id}-${index}-${partIndex}`; + return ( + <EditableShortcutKey + key={keyId} + isRecording={isRecording} + onStartRecording={() => onStartRecording({ shortcut })} + > + {keyPart} + </EditableShortcutKey> + ); + })} + </div> + {index < displayKeys.length - 1 && ( + <span className="text-muted-foreground text-xs">or</span> + )} + </div> + ))} + </div> + </div> + ); +} + +function EditableShortcutKey({ + children, + isRecording, + onStartRecording, +}: { + children: React.ReactNode; + isRecording: boolean; + onStartRecording: () => void; +}) { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onStartRecording(); + }; + + return ( + <Button + variant="outline" + size="sm" + onClick={handleClick} + title={ + isRecording ? "Press any key combination..." : "Click to edit shortcut" + } + > + {children} + </Button> + ); +} |
