Initial commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { X, Upload, Trash2, Loader2, Move, ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
|
||||
import { setCategoryCoverTransform, clearCategoryCover, type CategoryCoverSlot } from "@/app/actions/categoryCover";
|
||||
import { categoryCoverUrl } from "@/lib/assetUrls";
|
||||
|
||||
interface CoverState {
|
||||
path: string | null;
|
||||
zoom: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
initial: { portrait: CoverState; landscape: CoverState };
|
||||
initialSlot?: CategoryCoverSlot;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PHI = 1.618;
|
||||
const FRAME_H = 360;
|
||||
const PORTRAIT_H = FRAME_H;
|
||||
const PORTRAIT_W = Math.round(FRAME_H / PHI);
|
||||
const LANDSCAPE_H = FRAME_H;
|
||||
const LANDSCAPE_W = Math.round(FRAME_H * PHI);
|
||||
|
||||
const SLOT_LABELS: Record<CategoryCoverSlot, string> = { portrait: "P", landscape: "L" };
|
||||
const SLOT_KEYS: CategoryCoverSlot[] = ["portrait", "landscape"];
|
||||
|
||||
export function CategoryCoverEditor({ categoryId, categoryName, initial, initialSlot = "portrait", onClose }: Props) {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [slot, setSlot] = useState<CategoryCoverSlot>(initialSlot);
|
||||
const [slots, setSlots] = useState(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, start] = useTransition();
|
||||
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
|
||||
|
||||
const cur = slots[slot];
|
||||
const isLandscape = slot === "landscape";
|
||||
const W = isLandscape ? LANDSCAPE_W : PORTRAIT_W;
|
||||
const H = isLandscape ? LANDSCAPE_H : PORTRAIT_H;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const ACCEPTED = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
if (busy) return;
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
let imageItem: DataTransferItem | null = null;
|
||||
let unsupported: string | null = null;
|
||||
for (const it of items) {
|
||||
if (it.kind !== "file") continue;
|
||||
if (ACCEPTED.has(it.type)) { imageItem = it; break; }
|
||||
if (it.type.startsWith("image/")) unsupported = it.type;
|
||||
}
|
||||
if (imageItem) {
|
||||
e.preventDefault();
|
||||
const file = imageItem.getAsFile();
|
||||
if (file) uploadFile(file);
|
||||
} else if (unsupported) {
|
||||
e.preventDefault();
|
||||
setError("Unsupported image format — paste JPEG, PNG, or WebP");
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [busy, slot]);
|
||||
|
||||
function patchSlot(patch: Partial<CoverState>) {
|
||||
setSlots((s) => ({ ...s, [slot]: { ...s[slot], ...patch } }));
|
||||
}
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch(`/api/category-cover/${categoryId}?slot=${slot}`, { method: "POST", body: fd });
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error ?? `upload failed (${res.status})`);
|
||||
}
|
||||
const j = await res.json();
|
||||
patchSlot({ path: j.coverPath, zoom: 1, offsetX: 0, offsetY: 0 });
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent) {
|
||||
if (!cur.path) return;
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { x: e.clientX, y: e.clientY, ox: cur.offsetX, oy: cur.offsetY };
|
||||
}
|
||||
function onPointerMove(e: React.PointerEvent) {
|
||||
if (!dragRef.current) return;
|
||||
const dx = e.clientX - dragRef.current.x;
|
||||
const dy = e.clientY - dragRef.current.y;
|
||||
patchSlot({ offsetX: dragRef.current.ox + dx, offsetY: dragRef.current.oy + dy });
|
||||
}
|
||||
function onPointerUp() { dragRef.current = null; }
|
||||
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = previewRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: WheelEvent) => {
|
||||
if (!cur.path) return;
|
||||
e.preventDefault();
|
||||
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||||
patchSlot({ zoom: Math.max(0.5, Math.min(5, cur.zoom * factor)) });
|
||||
};
|
||||
el.addEventListener("wheel", handler, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cur.path, cur.zoom, slot]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setCategoryCoverTransform(categoryId, slot, {
|
||||
zoom: cur.zoom, offsetX: cur.offsetX, offsetY: cur.offsetY,
|
||||
});
|
||||
router.refresh();
|
||||
onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() { patchSlot({ zoom: 1, offsetX: 0, offsetY: 0 }); }
|
||||
|
||||
async function removeCover() {
|
||||
if (!confirm(`Remove ${SLOT_LABELS[slot]} cover for "${categoryName}"?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await clearCategoryCover(categoryId, slot);
|
||||
patchSlot({ path: null, zoom: 1, offsetX: 0, offsetY: 0 });
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in"
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--color-bg-0)] rounded-2xl border border-[var(--color-glass-border)] shadow-2xl p-4"
|
||||
style={{ width: `min(${isLandscape ? 800 : 480}px, calc(100vw - 32px))` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Category Cover</div>
|
||||
<div className="text-base font-medium truncate">{categoryName}</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] shrink-0">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
{SLOT_KEYS.map((k) => {
|
||||
const active = k === slot;
|
||||
const has = !!slots[k].path;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setSlot(k)}
|
||||
className={`flex items-center gap-1.5 text-xs font-mono font-semibold px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
active ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
|
||||
}`}
|
||||
>
|
||||
{SLOT_LABELS[k]}
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${has ? "bg-emerald-400" : "bg-white/20"}`} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 items-stretch">
|
||||
{cur.path ? (
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative shrink-0 rounded-xl overflow-hidden bg-black/30 select-none border-2 border-dashed"
|
||||
style={{
|
||||
width: W,
|
||||
height: H,
|
||||
cursor: "grab",
|
||||
containerType: "inline-size",
|
||||
borderColor: "color-mix(in oklch, var(--color-violet) 60%, transparent)",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={categoryCoverUrl(cur.path)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none border-2 border-dashed box-border"
|
||||
style={{
|
||||
// cqw scales offsets with frame width so the grid card
|
||||
// reproduces this preview at any column size.
|
||||
transform: `translate(-50%, -50%) translate(${cur.offsetX / W * 100}cqw, ${cur.offsetY / W * 100}cqw) scale(${cur.zoom})`,
|
||||
width: "100cqw",
|
||||
height: "auto",
|
||||
borderColor: "color-mix(in oklch, var(--color-cyan) 70%, transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] uppercase tracking-wider font-mono text-white flex items-center gap-1 pointer-events-none whitespace-nowrap px-1.5 py-0.5 rounded-md bg-black/70 backdrop-blur-sm">
|
||||
<Move className="w-3 h-3" /> drag · scroll
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="shrink-0 rounded-xl border border-dashed border-[var(--color-glass-border-strong)] text-center text-sm text-[var(--color-fg-muted)] hover:border-[var(--color-cyan)] hover:text-[var(--color-fg-dim)] transition-colors flex flex-col items-center justify-center gap-2"
|
||||
style={{ width: W, height: H }}
|
||||
>
|
||||
<Upload className="w-6 h-6" />
|
||||
Click Or Paste Image
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ZoomOut className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={5}
|
||||
step={0.01}
|
||||
value={cur.zoom}
|
||||
onChange={(e) => patchSlot({ zoom: Number(e.target.value) })}
|
||||
className="flex-1 accent-[var(--color-cyan)] min-w-0"
|
||||
disabled={!cur.path}
|
||||
/>
|
||||
<ZoomIn className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-[var(--color-fg-dim)] tabular-nums text-right -mt-1">
|
||||
{cur.zoom.toFixed(2)}x
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
||||
{cur.path ? "Replace" : "Upload"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
disabled={!cur.path || busy}
|
||||
title="Reset zoom & position"
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{cur.path && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCover}
|
||||
disabled={busy}
|
||||
title="Remove"
|
||||
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
hidden
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadFile(f); e.target.value = ""; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-xs text-red-400">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={busy || !cur.path}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null} Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Pencil, ImagePlus } from "lucide-react";
|
||||
import { CategoryCoverEditor } from "./CategoryCoverEditor";
|
||||
import { categoryCoverUrl } from "@/lib/assetUrls";
|
||||
import type { CategoryCoverSlot } from "@/app/actions/categoryCover";
|
||||
|
||||
interface CoverState {
|
||||
path: string | null;
|
||||
zoom: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categoryId: number;
|
||||
categoryName: string;
|
||||
categoryColor: string | null;
|
||||
portrait: CoverState;
|
||||
landscape: CoverState;
|
||||
}
|
||||
|
||||
const PHI = 1.618;
|
||||
// Match heights so the two slots sit on a clean baseline; widths follow
|
||||
// the golden ratio for each orientation.
|
||||
const FRAME_H = 240;
|
||||
const PORTRAIT_H = FRAME_H;
|
||||
const PORTRAIT_W = Math.round(FRAME_H / PHI);
|
||||
const LANDSCAPE_H = FRAME_H;
|
||||
const LANDSCAPE_W = Math.round(FRAME_H * PHI);
|
||||
|
||||
export function CategoryCoverPanel({ categoryId, categoryName, categoryColor, portrait, landscape }: Props) {
|
||||
const [open, setOpen] = useState<CategoryCoverSlot | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<CoverSlot
|
||||
label="Portrait"
|
||||
width={PORTRAIT_W}
|
||||
height={PORTRAIT_H}
|
||||
state={portrait}
|
||||
color={categoryColor}
|
||||
name={categoryName}
|
||||
onEdit={() => setOpen("portrait")}
|
||||
/>
|
||||
<CoverSlot
|
||||
label="Landscape"
|
||||
width={LANDSCAPE_W}
|
||||
height={LANDSCAPE_H}
|
||||
state={landscape}
|
||||
color={categoryColor}
|
||||
name={categoryName}
|
||||
onEdit={() => setOpen("landscape")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<CategoryCoverEditor
|
||||
categoryId={categoryId}
|
||||
categoryName={categoryName}
|
||||
initial={{ portrait, landscape }}
|
||||
initialSlot={open}
|
||||
onClose={() => setOpen(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverSlot({
|
||||
label,
|
||||
width,
|
||||
height,
|
||||
state,
|
||||
color,
|
||||
name,
|
||||
onEdit,
|
||||
}: {
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
state: CoverState;
|
||||
color: string | null;
|
||||
name: string;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">{label}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="group relative rounded-xl overflow-hidden border border-[var(--color-glass-border-strong)] hover:border-[var(--color-cyan)] transition-colors"
|
||||
style={{ width, height }}
|
||||
>
|
||||
{state.path ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={categoryCoverUrl(state.path)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.zoom})`,
|
||||
width,
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md bg-black/70 backdrop-blur-sm">
|
||||
<Pencil className="w-3 h-3" /> Edit
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<CategoryCoverPlaceholder name={name} color={color} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryCoverPlaceholder({ name, color }: { name: string; color: string | null }) {
|
||||
const accent = color ?? "var(--color-fg-muted)";
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-center px-3"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, color-mix(in oklch, ${accent} 25%, var(--color-bg-1)) 0%, var(--color-bg-1) 70%)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ background: accent }} />
|
||||
<div className="text-sm font-medium truncate max-w-full" style={{ color: accent }}>{name}</div>
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
|
||||
<ImagePlus className="w-3 h-3" /> add cover
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { CategoryCoverEditor } from "./CategoryCoverEditor";
|
||||
import { categoryCoverUrl } from "@/lib/assetUrls";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CategoryCoverSlot } from "@/app/actions/categoryCover";
|
||||
|
||||
// Mirror the editor's canonical frame so cqw-based offsets line up.
|
||||
const PHI = 1.618;
|
||||
const FRAME_H = 360;
|
||||
const CANONICAL_PORTRAIT_W = Math.round(FRAME_H / PHI);
|
||||
const CANONICAL_LANDSCAPE_W = Math.round(FRAME_H * PHI);
|
||||
|
||||
export interface CategoryGridCardProps {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
description: string | null;
|
||||
tagCount: number;
|
||||
imageCount: number;
|
||||
view: "portrait" | "landscape";
|
||||
portrait: { path: string | null; zoom: number; offsetX: number; offsetY: number };
|
||||
landscape: { path: string | null; zoom: number; offsetX: number; offsetY: number };
|
||||
}
|
||||
|
||||
export function CategoryGridCard(props: CategoryGridCardProps) {
|
||||
const { view, portrait, landscape, name, color, slug, description, tagCount, imageCount, id } = props;
|
||||
const [editing, setEditing] = useState<CategoryCoverSlot | null>(null);
|
||||
|
||||
const cur = view === "portrait" ? portrait : landscape;
|
||||
const aspect = view === "portrait" ? "aspect-[1/1.618]" : "aspect-[1.618/1]";
|
||||
const canonicalW = view === "portrait" ? CANONICAL_PORTRAIT_W : CANONICAL_LANDSCAPE_W;
|
||||
|
||||
function openEditor(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditing(view);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
href={`/category/${slug}`}
|
||||
className={cn(
|
||||
"group relative block rounded-2xl overflow-hidden glass glass-hover",
|
||||
aspect,
|
||||
)}
|
||||
style={{ containerType: "inline-size" }}
|
||||
>
|
||||
{cur.path ? (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={categoryCoverUrl(cur.path)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none h-auto"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${cur.offsetX / canonicalW * 100}cqw, ${cur.offsetY / canonicalW * 100}cqw) scale(${cur.zoom})`,
|
||||
width: "100cqw",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Placeholder name={name} color={color} />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openEditor}
|
||||
aria-label={`Edit ${view} cover`}
|
||||
title={`Edit ${view} cover`}
|
||||
className={cn(
|
||||
"absolute bottom-3 right-3 z-20 w-8 h-8 grid place-items-center rounded-md",
|
||||
"bg-black/60 backdrop-blur-md text-white border border-white/20",
|
||||
"opacity-0 group-hover:opacity-100 transition-all",
|
||||
"hover:bg-[var(--color-cyan)]/30 hover:border-[var(--color-cyan)] hover:text-[var(--color-cyan)]",
|
||||
"hover:scale-110 hover:shadow-lg active:scale-95",
|
||||
)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 p-3 pt-10 bg-gradient-to-t from-black/85 via-black/55 to-transparent">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ background: color ?? "var(--color-fg-muted)" }}
|
||||
/>
|
||||
<div className="font-semibold truncate text-white">{name}</div>
|
||||
</div>
|
||||
{view === "landscape" && description && (
|
||||
<p className="text-[11px] text-white/70 mt-1 line-clamp-2">{description}</p>
|
||||
)}
|
||||
<div className="text-[10px] font-mono text-white/60 tabular-nums mt-1">
|
||||
{tagCount} tag{tagCount === 1 ? "" : "s"} · {imageCount} cover{imageCount === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{editing && (
|
||||
<CategoryCoverEditor
|
||||
categoryId={id}
|
||||
categoryName={name}
|
||||
initial={{ portrait, landscape }}
|
||||
initialSlot={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder({ name, color }: { name: string; color: string | null }) {
|
||||
const accent = color ?? "var(--color-fg-muted)";
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, color-mix(in oklch, ${accent} 30%, var(--color-bg-1)) 0%, var(--color-bg-1) 80%)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-center px-3 text-2xl font-semibold tracking-tight uppercase opacity-70"
|
||||
style={{
|
||||
color: accent,
|
||||
textShadow: "0 2px 8px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Search, Loader2 } from "lucide-react";
|
||||
import { setTagCategory } from "@/app/actions/tagCategories";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AssignerTag {
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
currentCategoryId: number | null;
|
||||
currentCategoryName: string | null;
|
||||
}
|
||||
|
||||
export function CategoryTagAssigner({
|
||||
categoryId,
|
||||
tags,
|
||||
}: {
|
||||
categoryId: number;
|
||||
tags: AssignerTag[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [pending, start] = useTransition();
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return tags;
|
||||
return tags.filter((t) => t.name.toLowerCase().includes(q));
|
||||
}, [tags, query]);
|
||||
|
||||
function toggle(tag: AssignerTag) {
|
||||
const inThis = tag.currentCategoryId === categoryId;
|
||||
const next = inThis ? null : categoryId;
|
||||
setBusyId(tag.id);
|
||||
start(async () => {
|
||||
await setTagCategory(tag.id, next);
|
||||
setBusyId(null);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl p-3">
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter tags…"
|
||||
className="w-full bg-[var(--color-bg-1)]/60 rounded-lg pl-9 pr-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] border border-[var(--color-glass-border)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[420px] overflow-y-auto -mx-1 px-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center text-sm text-[var(--color-fg-muted)] italic py-4">
|
||||
{query ? "No tags match." : "No tags exist yet — create one from the Tags page first."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1">
|
||||
{filtered.map((t) => {
|
||||
const inThis = t.currentCategoryId === categoryId;
|
||||
const elsewhere = !inThis && t.currentCategoryId != null;
|
||||
const busy = busyId === t.id && pending;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => toggle(t)}
|
||||
disabled={pending}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left transition-colors disabled:opacity-50",
|
||||
inThis
|
||||
? "bg-[var(--color-violet)]/15 text-[var(--color-violet)] ring-1 ring-[var(--color-violet)]/40"
|
||||
: "hover:bg-[var(--color-glass)] text-[var(--color-fg)]",
|
||||
)}
|
||||
title={
|
||||
inThis
|
||||
? "Click to remove from this category"
|
||||
: elsewhere
|
||||
? `Currently in "${t.currentCategoryName}". Click to move it here.`
|
||||
: "Uncategorised. Click to assign to this category."
|
||||
}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
) : inThis ? (
|
||||
<Check className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<span className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate flex-1">{t.name}</span>
|
||||
<span className="text-[10px] font-mono text-[var(--color-fg-muted)] tabular-nums">{t.count}</span>
|
||||
{elsewhere && (
|
||||
<span
|
||||
className="text-[10px] font-mono uppercase tracking-wider px-1.5 py-0.5 rounded bg-[var(--color-glass)] text-[var(--color-fg-muted)] truncate max-w-[100px]"
|
||||
title={t.currentCategoryName ?? ""}
|
||||
>
|
||||
{t.currentCategoryName}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--color-fg-muted)] mt-3 leading-relaxed">
|
||||
A tag can only belong to one category. Selecting a tag that's in another category moves it here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Layers, ChevronDown, Check, Loader2, X } from "lucide-react";
|
||||
import { setTagCategory } from "@/app/actions/tagCategories";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CategoryOption {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export function TagCategoryPicker({
|
||||
tagId,
|
||||
currentCategoryId,
|
||||
categories,
|
||||
}: {
|
||||
tagId: number;
|
||||
currentCategoryId: number | null;
|
||||
categories: CategoryOption[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const current = categories.find((c) => c.id === currentCategoryId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||
window.addEventListener("mousedown", onClick);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onClick);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function pick(id: number | null) {
|
||||
if (id === currentCategoryId) { setOpen(false); return; }
|
||||
start(async () => {
|
||||
await setTagCategory(tagId, id);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((s) => !s)}
|
||||
disabled={pending}
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs px-2.5 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{pending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Layers className="w-3.5 h-3.5" />}
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Category:</span>
|
||||
{current ? (
|
||||
<span className="flex items-center gap-1.5 text-[var(--color-fg)]">
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: current.color ?? "var(--color-fg-muted)" }} />
|
||||
{current.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic">Uncategorised</span>
|
||||
)}
|
||||
<ChevronDown className={cn("w-3 h-3 transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute top-full left-0 mt-1 min-w-[220px] max-h-[320px] overflow-y-auto rounded-lg border border-[var(--color-glass-border-strong)] bg-[var(--color-bg-0)] shadow-lg backdrop-blur-xl py-1 z-50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(null)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left",
|
||||
currentCategoryId == null ? "text-[var(--color-cyan)] bg-[var(--color-glass)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
{currentCategoryId == null ? <Check className="w-3 h-3" /> : <X className="w-3 h-3 opacity-50" />}
|
||||
<span className="italic">Uncategorised</span>
|
||||
</button>
|
||||
{categories.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-[var(--color-fg-muted)] italic">
|
||||
No categories yet.{" "}
|
||||
<Link href="/category" className="underline hover:text-[var(--color-fg)]">
|
||||
Create one →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
categories.map((c) => {
|
||||
const active = c.id === currentCategoryId;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => pick(c.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left",
|
||||
active ? "text-[var(--color-cyan)] bg-[var(--color-glass)]" : "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
>
|
||||
{active ? <Check className="w-3 h-3" /> : <span className="w-3 h-3" />}
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: c.color ?? "var(--color-fg-muted)" }} />
|
||||
<span className="truncate">{c.name}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user