230 lines
8.8 KiB
TypeScript
230 lines
8.8 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|