Skip to content
File

Blob: src/components/TemplatePicker.tsx

typescript116 lines
1import { useEffect, useRef, useState } from "react";
2import { createPortal } from "react-dom";
3import { cn } from "../lib/cn";
4import { ChevronDownIcon, CheckIcon, TemplateIcon } from "./icons";
5import { getTemplate, groupedTemplates } from "../templates";
6import { CUSTOM_TEMPLATE_ID } from "../hooks/useEditor";
7import type { Template } from "../types";
8 
9interface TemplatePickerProps {
10 currentId: string;
11 onSelect: (template: Template) => void;
12}
13 
14/** A small aspect-ratio swatch (max 30px on the long edge). */
15function swatch(aspect: number | null) {
16 const a = aspect ?? 1;
17 const long = 30;
18 const width = a >= 1 ? long : long * a;
19 const height = a >= 1 ? long / a : long;
20 return { width: `${width}px`, height: `${height}px` };
21}
22 
23export function TemplatePicker({ currentId, onSelect }: TemplatePickerProps) {
24 const [open, setOpen] = useState(false);
25 const [pos, setPos] = useState<{ top: number; left: number }>();
26 const btnRef = useRef<HTMLButtonElement>(null);
27 const isCustom = currentId === CUSTOM_TEMPLATE_ID;
28 const label = isCustom ? "Custom crop" : getTemplate(currentId).name;
29 
30 function toggle() {
31 const r = btnRef.current?.getBoundingClientRect();
32 if (r) setPos({ top: r.bottom + 8, left: r.left });
33 setOpen((o) => !o);
34 }
35 
36 useEffect(() => {
37 if (!open) return;
38 const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
39 window.addEventListener("keydown", onKey);
40 return () => window.removeEventListener("keydown", onKey);
41 }, [open]);
42 
43 return (
44 <>
45 <button
46 ref={btnRef}
47 onClick={toggle}
48 aria-label="Choose template"
49 aria-haspopup="menu"
50 aria-expanded={open}
51 className="flex w-full items-center justify-between rounded-lg border border-zinc-700/60 bg-zinc-800 px-3 py-2.5 text-left hover:border-zinc-600"
52 >
53 <span className="flex items-center gap-2">
54 <TemplateIcon className="size-4 text-accent-400" />
55 <span className="text-sm font-medium text-zinc-100">{label}</span>
56 </span>
57 <ChevronDownIcon className={cn("size-4 text-zinc-400 transition-transform", open && "rotate-180")} />
58 </button>
59
60 {open &&
61 pos &&
62 createPortal(
63 <>
64 <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} aria-hidden="true" />
65 <div
66 className="fixed z-50 max-h-[70vh] w-80 overflow-y-auto rounded-xl border border-zinc-700/60 bg-zinc-800 p-2 shadow-2xl shadow-black/50"
67 style={{ top: pos.top, left: pos.left }}
68 >
69 {groupedTemplates().map((g) => (
70 <div key={g.group} className="mb-1 last:mb-0">
71 <p className="px-2 py-1 text-[0.65rem] font-semibold uppercase tracking-wider text-zinc-500">
72 {g.group}
73 </p>
74 <div className="grid grid-cols-2 gap-1.5">
75 {g.items.map((t) => {
76 const active = t.id === currentId;
77 return (
78 <button
79 key={t.id}
80 onClick={() => {
81 onSelect(t);
82 setOpen(false);
83 }}
84 title={t.description}
85 className={cn(
86 "flex flex-col gap-2 rounded-lg border p-2.5 text-left",
87 active
88 ? "border-accent-500 bg-accent-500/10"
89 : "border-zinc-700/60 bg-zinc-800/60 hover:border-zinc-600 hover:bg-zinc-700/40",
90 )}
91 >
92 <span className="flex h-8 items-center justify-center">
93 <span className="rounded-sm border border-zinc-600 bg-zinc-700" style={swatch(t.aspect)} />
94 </span>
95 <span className="flex items-center justify-between gap-1">
96 <span className="truncate text-xs font-medium text-zinc-100">{t.name}</span>
97 {active && <CheckIcon className="size-3.5 shrink-0 text-accent-400" />}
98 </span>
99 <span className="text-[0.65rem] text-zinc-400">
100 {t.output ? `${t.output.width}×${t.output.height}` : "Native size"} ·{" "}
101 {t.encoder.format.toUpperCase()}
102 </span>
103 </button>
104 );
105 })}
106 </div>
107 </div>
108 ))}
109 </div>
110 </>,
111 document.body,
112 )}
113 </>
114 );
115}