Files
embycovers/frontend/src/pages/emby/Collections.tsx
T
2026-06-08 00:01:55 +12:00

230 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet, apiPost, apiPostImage } from "../../api";
import { PageHead, Empty, Loading } from "../../components/ui";
import { IconCheck, IconLayers, IconSearch, IconWand } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface Collection {
id: string;
name: string;
child_count: number;
poster_url: string | null;
}
interface Opts {
target_type: string;
text: string;
text_color: string;
text_align: string;
text_position: string;
text_scale: number;
darkness: number;
}
const DEFAULTS: Opts = {
target_type: "Thumb",
text: "",
text_color: "#FFFFFF",
text_align: "center",
text_position: "bottom",
text_scale: 1.0,
darkness: 0.18,
};
export default function Collections() {
const toast = useToast();
const [query, setQuery] = useState("");
const [list, setList] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Collection | null>(null);
const [opts, setOpts] = useState<Opts>(DEFAULTS);
const [preview, setPreview] = useState<string | null>(null);
const [rendering, setRendering] = useState(false);
const [applying, setApplying] = useState(false);
const debounceRef = useRef<number>();
const set = <K extends keyof Opts>(k: K, v: Opts[K]) => setOpts((o) => ({ ...o, [k]: v }));
const load = useCallback(() => {
setLoading(true);
apiGet<{ items: Collection[] }>(`/api/collections?q=${encodeURIComponent(query.trim())}&limit=60`)
.then((d) => setList(d.items))
.catch((e) => toast(e.message, "err"))
.finally(() => setLoading(false));
}, [query, toast]);
useEffect(() => {
load();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function select(c: Collection) {
setSelected(c);
setPreview(null);
setOpts({ ...DEFAULTS, text: c.name });
}
const generate = useCallback(async () => {
if (!selected) return;
setRendering(true);
try {
const { url } = await apiPostImage("/api/collections/generate", { item_id: selected.id, ...opts });
setPreview((p) => {
if (p) URL.revokeObjectURL(p);
return url;
});
} catch (e: any) {
toast(e.message, "err");
} finally {
setRendering(false);
}
}, [selected, opts, toast]);
useEffect(() => {
if (!selected) return;
window.clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(generate, 350);
return () => window.clearTimeout(debounceRef.current);
}, [selected, opts]); // eslint-disable-line react-hooks/exhaustive-deps
async function apply() {
if (!selected) return;
setApplying(true);
try {
await apiPost("/api/collections/apply", { item_id: selected.id, ...opts });
toast(`Applied ${opts.target_type} to ${selected.name}`, "ok");
} catch (e: any) {
toast(e.message, "err");
} finally {
setApplying(false);
}
}
return (
<>
<PageHead title="Collection Art">Generate cover artwork for your Emby collections with custom titling.</PageHead>
<div className="workbench">
<div className="panel" style={{ display: "flex", flexDirection: "column", maxHeight: "calc(100vh - 200px)" }}>
<div style={{ padding: 14, borderBottom: "1px solid var(--border)" }}>
<form
className="search-inner"
onSubmit={(e) => {
e.preventDefault();
load();
}}
>
<IconSearch />
<input className="input" placeholder="Search collections…" value={query} onChange={(e) => setQuery(e.target.value)} />
</form>
</div>
<div style={{ overflowY: "auto", padding: 8 }}>
{loading ? (
<Loading />
) : !list.length ? (
<Empty icon={<IconLayers />}>No collections found.</Empty>
) : (
list.map((c) => (
<div key={c.id} className={`result-item ${selected?.id === c.id ? "active" : ""}`} onClick={() => select(c)}>
{c.poster_url ? <img className="result-poster" src={c.poster_url} loading="lazy" alt="" /> : <div className="result-poster" />}
<div className="grow">
<div className="result-name">{c.name}</div>
<div className="result-sub">{c.child_count} items</div>
</div>
</div>
))
)}
</div>
</div>
<div className="preview-shell">
{!selected ? (
<Empty icon={<IconLayers />}>Select a collection to design its artwork.</Empty>
) : (
<>
<div className="preview-frame" style={{ aspectRatio: opts.target_type === "Primary" ? "2 / 3" : "16 / 9", maxWidth: opts.target_type === "Primary" ? 360 : "100%" }}>
{preview ? <img src={preview} alt="preview" /> : <Loading />}
</div>
<div className="row" style={{ width: "100%" }}>
<button className="btn grow" onClick={generate} disabled={rendering}>
<IconWand /> Regenerate
</button>
<button className="btn btn-primary grow" onClick={apply} disabled={applying || !preview}>
{applying ? <span className="spinner" /> : <IconCheck />} Apply
</button>
</div>
</>
)}
</div>
<div className="panel scroll-col" style={{ maxHeight: "calc(100vh - 200px)" }}>
<div className="panel-head">
<h3>Controls</h3>
</div>
<div className="panel-body col" style={{ gap: 16 }}>
{!selected ? (
<p className="hint">Pick a collection first.</p>
) : (
<>
<div className="field">
<label className="field-label">Target image</label>
<div className="seg">
<button className={`seg-btn ${opts.target_type === "Thumb" ? "active" : ""}`} onClick={() => set("target_type", "Thumb")}>
Thumb
</button>
<button className={`seg-btn ${opts.target_type === "Primary" ? "active" : ""}`} onClick={() => set("target_type", "Primary")}>
Primary
</button>
</div>
</div>
<div className="field">
<label className="field-label">Title text</label>
<input className="input" value={opts.text} onChange={(e) => set("text", e.target.value)} />
</div>
<div className="field">
<label className="field-label">Text align</label>
<div className="seg">
{["left", "center", "right"].map((a) => (
<button key={a} className={`seg-btn ${opts.text_align === a ? "active" : ""}`} onClick={() => set("text_align", a)}>
{a}
</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">Text position</label>
<div className="seg">
{["top", "center", "bottom"].map((p) => (
<button key={p} className={`seg-btn ${opts.text_position === p ? "active" : ""}`} onClick={() => set("text_position", p)}>
{p}
</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">
Text scale <span className="mono">{opts.text_scale.toFixed(2)}×</span>
</label>
<input type="range" min={0.65} max={1.8} step={0.05} value={opts.text_scale} onChange={(e) => set("text_scale", +e.target.value)} />
</div>
<div className="field">
<label className="field-label">
Darkness <span className="mono">{Math.round(opts.darkness * 100)}%</span>
</label>
<input type="range" min={0} max={0.85} step={0.05} value={opts.darkness} onChange={(e) => set("darkness", +e.target.value)} />
</div>
<div className="field">
<label className="field-label">Text color</label>
<div className="row">
<input type="color" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
<input className="input grow" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
</div>
</div>
</>
)}
</div>
</div>
</div>
</>
);
}