diff --git a/package.json b/package.json index 57bf4fcc..31fd50de 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ }, "devDependencies": { "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.4.3", "@types/bun": "^1.3.14", "@types/node": "^25", diff --git a/src/app/page.tsx b/src/app/page.tsx index 188ed440..3164a765 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,3 +22,7 @@ export default function Home() { ); } +
+ +
+ diff --git a/src/components/CustomTemplateManager.tsx b/src/components/CustomTemplateManager.tsx new file mode 100644 index 00000000..f05529c1 --- /dev/null +++ b/src/components/CustomTemplateManager.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + getTemplates, + deleteTemplate, + renameTemplate, +} from "@/lib/templateStorage"; + +interface Props { + onApplyTemplate: (recipe: any) => void; +} + +export default function CustomTemplateManager({ + onApplyTemplate, +}: Props) { + const [templates, setTemplates] = useState([]); + + const refreshTemplates = () => { + setTemplates(getTemplates()); + }; + + useEffect(() => { + refreshTemplates(); + }, []); + + return ( +
+ {templates.length === 0 && ( +

+ No saved templates +

+ )} + + {templates.map((template) => ( +
+

+ {template.name} +

+ +
+ + + + + +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index a12c1f41..69285c97 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -16,7 +16,7 @@ import FormatSelector from "./FormatSelector"; import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; -import ImageOverlay from "./ImageOverlay" +import ImageOverlay from "./ImageOverlay"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; @@ -27,6 +27,9 @@ import { import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { loadOverlayState, persistOverlayState } from "@/lib/editorPersistence"; +import CustomTemplateManager from "./CustomTemplateManager"; + +import { saveTemplate, } from "@/lib/templateStorage"; interface SectionProps { icon: React.ReactNode; @@ -123,37 +126,37 @@ function KeyboardShortcutsPanel() { const [open, setOpen] = useState(false); const shortcuts: { keys: React.ReactNode[]; label: string }[] = [ - { - keys: [ - Ctrl, - +, - Shift, - +, - E - ], - label: "Export video", - }, - { - keys: [M], - label: "Toggle audio mute", - }, - { - keys: [R], - label: "Reset all settings", - }, - { - keys: [Esc], - label: "Cancel export", - }, - { - keys: [1, , 9], - label: "Switch preset by index", - }, - { - keys: [?], - label: "Toggle this panel", - }, -]; + { + keys: [ + Ctrl, + +, + Shift, + +, + E + ], + label: "Export video", + }, + { + keys: [M], + label: "Toggle audio mute", + }, + { + keys: [R], + label: "Reset all settings", + }, + { + keys: [Esc], + label: "Cancel export", + }, + { + keys: [1, , 9], + label: "Switch preset by index", + }, + { + keys: [?], + label: "Toggle this panel", + }, + ]; return (
@@ -221,7 +224,7 @@ export default function VideoEditor() { handleExport, status, cancelExport, - onToggleShortcutsModal: () => {}, + onToggleShortcutsModal: () => { }, }); const [copied, setCopied] = useState(false); @@ -289,7 +292,7 @@ export default function VideoEditor() { useEffect(() => { if (status === "done" && downloadRef.current) { - + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; downloadRef.current.scrollIntoView({ behavior: prefersReducedMotion ? "instant" : "smooth", @@ -298,19 +301,19 @@ export default function VideoEditor() { } }, [status]); useEffect(() => { -const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (file) { - e.preventDefault(); - e.returnValue = ""; - } -}; + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (file) { + e.preventDefault(); + e.returnValue = ""; + } + }; -window.addEventListener("beforeunload", handleBeforeUnload); + window.addEventListener("beforeunload", handleBeforeUnload); -return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); -}; -}, [file]); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [file]); const isProcessing = status === "loading-engine" || status === "exporting"; const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); @@ -337,8 +340,8 @@ return () => { const qualityLabel = recipe.quality <= 21 ? "High" : recipe.quality <= 25 - ? "Balanced" - : "Small file"; + ? "Balanced" + : "Small file"; return `Exporting to ${width}×${height} ${recipe.format.toUpperCase()} • ${framingLabel} • ${speedLabel} • Quality: ${qualityLabel}`; }, [recipe]); @@ -348,6 +351,23 @@ return () => { if (videoSrc) URL.revokeObjectURL(videoSrc); }; }, [videoSrc]); + const handleSaveTemplate = () => { + const name = prompt("Template name"); + + if (!name?.trim()) return; + + saveTemplate({ + id: + typeof crypto !== "undefined" && + crypto.randomUUID + ? crypto.randomUUID() + : Date.now().toString(), + name: name.trim(), + recipe: { ...recipe }, + }); + + alert("Template saved!"); + }; return (
@@ -367,46 +387,46 @@ return () => {
-
-

- REFRAME -

-

- Your video, any format -

-
- - No login. No ads. 100% private. -
-
-
- - No login. No ads. 100% private - your video never leaves your device. -
-
+
+

+ REFRAME +

+

+ Your video, any format +

+
+ + No login. No ads. 100% private. +
+
+
+ + No login. No ads. 100% private - your video never leaves your device. +
+
@@ -667,10 +687,12 @@ return () => { )}
-
+
{!file && (

@@ -681,83 +703,84 @@ return () => {

)} -
- } - title="Resize & Aspect Ratio" - isOpen={openSections.resize} - onToggle={() => toggleSection("resize")} - delay={50} - > - {recommendedPreset && ( -
-

- We detected a {recommendedPreset.label.replace(/\s/g, "")} video → Recommended: {(recommendedPreset.platform.split("·")[0] ?? "").trim()} ({recommendedPreset.label.replace(/\s/g, "")}) -

-
- )} -
- - -
-
-
- - + {/* Resize INFO ONLY (no duplicate controls) */} +
+
+
+ + + Resize & Aspect Ratio + +
+ + {recommendedPreset && ( +
+

+ We detected a {recommendedPreset.label.replace(/\s/g, "")} video → + Recommended: {(recommendedPreset.platform.split("·")[0] ?? "").trim()} ( + {recommendedPreset.label.replace(/\s/g, "")}) +

+
+ )} + +

+ Resize controls are available in the left panel. +

+ {/* Copy + Reset */} +
+ + + +
+ + {/* Keyboard shortcuts */} + {/* Export summary */} {file && (

{exportSummary}

)} - + {/* Templates */} +
+
} title="Templates"> + + + updateRecipe(savedRecipe)} + /> +
+
- {file && !isProcessing && ( -

- {isMac ? "⌘" : "Ctrl"} + Enter to export -

- )}
+
); -} +} \ No newline at end of file diff --git a/src/lib/templateStorage.tsx b/src/lib/templateStorage.tsx new file mode 100644 index 00000000..88d8a2f9 --- /dev/null +++ b/src/lib/templateStorage.tsx @@ -0,0 +1,59 @@ +export interface CustomTemplate { + id: string; + name: string; + recipe: any; +} + +const STORAGE_KEY = "reframe-custom-templates"; + +export const getTemplates = (): CustomTemplate[] => { + if (typeof window === "undefined") return []; + + try { + const data = localStorage.getItem(STORAGE_KEY); + return data ? JSON.parse(data) : []; + } catch { + return []; + } +}; + +export const saveTemplate = (template: CustomTemplate) => { + const templates = getTemplates(); + + templates.push(template); + + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(templates) + ); + + console.log("Saved templates:", templates); +}; + +export const deleteTemplate = (id: string) => { + const updated = getTemplates().filter( + (template) => template.id !== id + ); + + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(updated) + ); +}; + +export const renameTemplate = ( + id: string, + name: string +) => { + const updated = getTemplates().map( + (template) => + template.id === id + ? { ...template, name } + : template + ); + + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(updated) + ); +}; \ No newline at end of file