summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/src/actions/__tests__/freeze-frame-store.test.ts98
-rw-r--r--apps/web/src/actions/components/freeze-frame-dialog.tsx109
-rw-r--r--apps/web/src/actions/freeze-frame-store.ts72
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx2
4 files changed, 281 insertions, 0 deletions
diff --git a/apps/web/src/actions/__tests__/freeze-frame-store.test.ts b/apps/web/src/actions/__tests__/freeze-frame-store.test.ts
new file mode 100644
index 00000000..94e1566e
--- /dev/null
+++ b/apps/web/src/actions/__tests__/freeze-frame-store.test.ts
@@ -0,0 +1,98 @@
+import { beforeEach, describe, expect, test } from "bun:test";
+import {
+ DEFAULT_FREEZE_DURATION_SECONDS,
+ isValidFreezeDuration,
+ MAX_FREEZE_DURATION_SECONDS,
+ useFreezeFrameStore,
+} from "../freeze-frame-store";
+
+function resetStore() {
+ useFreezeFrameStore.setState({
+ isOpen: false,
+ lastDurationSeconds: DEFAULT_FREEZE_DURATION_SECONDS,
+ resolver: null,
+ });
+}
+
+describe("freeze-frame store", () => {
+ beforeEach(() => {
+ resetStore();
+ });
+
+ describe("isValidFreezeDuration", () => {
+ test("accepts positive durations up to the max, including decimals", () => {
+ expect(isValidFreezeDuration({ seconds: 3 })).toBe(true);
+ expect(isValidFreezeDuration({ seconds: 2.5 })).toBe(true);
+ expect(isValidFreezeDuration({ seconds: 0.1 })).toBe(true);
+ expect(
+ isValidFreezeDuration({ seconds: MAX_FREEZE_DURATION_SECONDS }),
+ ).toBe(true);
+ });
+
+ test("rejects non-positive, over-max, and non-finite durations", () => {
+ expect(isValidFreezeDuration({ seconds: 0 })).toBe(false);
+ expect(isValidFreezeDuration({ seconds: -1 })).toBe(false);
+ expect(isValidFreezeDuration({ seconds: 600.1 })).toBe(false);
+ expect(isValidFreezeDuration({ seconds: Number.NaN })).toBe(false);
+ expect(isValidFreezeDuration({ seconds: Number.POSITIVE_INFINITY })).toBe(
+ false,
+ );
+ });
+ });
+
+ describe("requestDuration", () => {
+ test("opens the dialog and resolves with the confirmed seconds", async () => {
+ const promise = useFreezeFrameStore.getState().requestDuration();
+ expect(useFreezeFrameStore.getState().isOpen).toBe(true);
+
+ useFreezeFrameStore.getState().confirmDuration({ seconds: 5 });
+
+ await expect(promise).resolves.toBe(5);
+ expect(useFreezeFrameStore.getState().isOpen).toBe(false);
+ });
+
+ test("remembers the confirmed duration as the last-used value", async () => {
+ const promise = useFreezeFrameStore.getState().requestDuration();
+ useFreezeFrameStore.getState().confirmDuration({ seconds: 5 });
+ await promise;
+
+ expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe(5);
+ });
+
+ test("resolves null on cancel and keeps the last-used duration", async () => {
+ useFreezeFrameStore.setState({ lastDurationSeconds: 7 });
+
+ const promise = useFreezeFrameStore.getState().requestDuration();
+ expect(useFreezeFrameStore.getState().isOpen).toBe(true);
+
+ useFreezeFrameStore.getState().cancelDuration();
+
+ await expect(promise).resolves.toBeNull();
+ expect(useFreezeFrameStore.getState().isOpen).toBe(false);
+ expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe(7);
+ });
+
+ test("ignores an invalid confirm and leaves the dialog open", () => {
+ useFreezeFrameStore.getState().requestDuration();
+
+ useFreezeFrameStore.getState().confirmDuration({ seconds: 0 });
+ useFreezeFrameStore.getState().confirmDuration({ seconds: 601 });
+
+ expect(useFreezeFrameStore.getState().isOpen).toBe(true);
+ expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe(
+ DEFAULT_FREEZE_DURATION_SECONDS,
+ );
+ });
+
+ test("a new request supersedes a pending one by cancelling it", async () => {
+ const first = useFreezeFrameStore.getState().requestDuration();
+ const second = useFreezeFrameStore.getState().requestDuration();
+
+ await expect(first).resolves.toBeNull();
+ expect(useFreezeFrameStore.getState().isOpen).toBe(true);
+
+ useFreezeFrameStore.getState().confirmDuration({ seconds: 2 });
+ await expect(second).resolves.toBe(2);
+ });
+ });
+});
diff --git a/apps/web/src/actions/components/freeze-frame-dialog.tsx b/apps/web/src/actions/components/freeze-frame-dialog.tsx
new file mode 100644
index 00000000..a6a7e0ae
--- /dev/null
+++ b/apps/web/src/actions/components/freeze-frame-dialog.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ isValidFreezeDuration,
+ MAX_FREEZE_DURATION_SECONDS,
+ useFreezeFrameStore,
+} from "@/actions/freeze-frame-store";
+
+export function FreezeFrameDialog() {
+ const isOpen = useFreezeFrameStore((state) => state.isOpen);
+ const cancelDuration = useFreezeFrameStore((state) => state.cancelDuration);
+
+ return (
+ <Dialog
+ open={isOpen}
+ onOpenChange={(open) => {
+ if (!open) {
+ cancelDuration();
+ }
+ }}
+ >
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Freeze frame</DialogTitle>
+ <DialogDescription>
+ How long should the frozen frame stay on screen?
+ </DialogDescription>
+ </DialogHeader>
+ {isOpen && <FreezeFrameForm />}
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+function FreezeFrameForm() {
+ const lastDurationSeconds = useFreezeFrameStore(
+ (state) => state.lastDurationSeconds,
+ );
+ const confirmDuration = useFreezeFrameStore((state) => state.confirmDuration);
+ const cancelDuration = useFreezeFrameStore((state) => state.cancelDuration);
+ const [value, setValue] = useState(String(lastDurationSeconds));
+
+ const seconds = Number(value);
+ const showError = value.trim() !== "" && !isValidFreezeDuration({ seconds });
+ const canConfirm = isValidFreezeDuration({ seconds });
+
+ const handleConfirm = () => {
+ if (canConfirm) {
+ confirmDuration({ seconds });
+ }
+ };
+
+ return (
+ <>
+ <DialogBody className="gap-3">
+ <Label htmlFor="freeze-frame-duration">Duration (seconds)</Label>
+ <Input
+ id="freeze-frame-duration"
+ value={value}
+ inputMode="decimal"
+ aria-invalid={showError}
+ onChange={(e) => setValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleConfirm();
+ }
+ }}
+ placeholder="e.g. 2.5"
+ />
+ {showError && (
+ <p className="text-destructive text-xs">
+ Enter a duration greater than 0 and at most{" "}
+ {MAX_FREEZE_DURATION_SECONDS} seconds.
+ </p>
+ )}
+ </DialogBody>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ cancelDuration();
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={handleConfirm} disabled={!canConfirm}>
+ Freeze
+ </Button>
+ </DialogFooter>
+ </>
+ );
+}
diff --git a/apps/web/src/actions/freeze-frame-store.ts b/apps/web/src/actions/freeze-frame-store.ts
new file mode 100644
index 00000000..17102780
--- /dev/null
+++ b/apps/web/src/actions/freeze-frame-store.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export const DEFAULT_FREEZE_DURATION_SECONDS = 3;
+export const MAX_FREEZE_DURATION_SECONDS = 600;
+
+export function isValidFreezeDuration({
+ seconds,
+}: {
+ seconds: number;
+}): boolean {
+ return (
+ Number.isFinite(seconds) &&
+ seconds > 0 &&
+ seconds <= MAX_FREEZE_DURATION_SECONDS
+ );
+}
+
+type DurationResolver = ({ seconds }: { seconds: number | null }) => void;
+
+interface FreezeFrameState {
+ isOpen: boolean;
+ lastDurationSeconds: number;
+ resolver: DurationResolver | null;
+ requestDuration: () => Promise<number | null>;
+ confirmDuration: ({ seconds }: { seconds: number }) => void;
+ cancelDuration: () => void;
+}
+
+export const useFreezeFrameStore = create<FreezeFrameState>()(
+ persist(
+ (set, get) => ({
+ isOpen: false,
+ lastDurationSeconds: DEFAULT_FREEZE_DURATION_SECONDS,
+ resolver: null,
+
+ requestDuration: () =>
+ new Promise<number | null>((resolve) => {
+ // A new invocation supersedes any pending one: resolve it as
+ // cancelled so its handler aborts instead of hanging.
+ get().resolver?.({ seconds: null });
+ set({
+ isOpen: true,
+ resolver: ({ seconds }) => resolve(seconds),
+ });
+ }),
+ confirmDuration: ({ seconds }) => {
+ if (!isValidFreezeDuration({ seconds })) {
+ return;
+ }
+ get().resolver?.({ seconds });
+ set({
+ isOpen: false,
+ resolver: null,
+ lastDurationSeconds: seconds,
+ });
+ },
+ cancelDuration: () => {
+ get().resolver?.({ seconds: null });
+ set({ isOpen: false, resolver: null });
+ },
+ }),
+ {
+ name: "freeze-frame-store",
+ partialize: (state) => ({
+ lastDurationSeconds: state.lastDurationSeconds,
+ }),
+ },
+ ),
+);
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 680dfff4..b3f1a933 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -14,6 +14,7 @@ import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/project/components/migration-dialog";
+import { FreezeFrameDialog } from "@/actions/components/freeze-frame-dialog";
import { usePanelStore } from "@/editor/panel-store";
import { usePasteMedia } from "@/media/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate";
@@ -50,6 +51,7 @@ export default function Editor() {
</div>
<Onboarding />
<MigrationDialog />
+ <FreezeFrameDialog />
<ChangelogNotification />
</div>
</EditorProvider>