Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { Upload } from "lucide-react";
import { TagImportModal } from "./TagImportModal";
export function TagImportButton({
existingTagNames,
existingCategoryNames,
}: {
existingTagNames: string[];
existingCategoryNames: string[];
}) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
title="Bulk import tags"
>
<Upload className="w-4 h-4" /> Import
</button>
{open && (
<TagImportModal
existingTagNames={new Set(existingTagNames.map((n) => n.toLowerCase()))}
existingCategoryNames={new Set(existingCategoryNames.map((n) => n.toLowerCase()))}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
+323
View File
@@ -0,0 +1,323 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createPortal } from "react-dom";
import { Loader2, X, FileCode, FileText, Table } from "lucide-react";
import { bulkImportTags, type BulkImportRow, type BulkImportResult } from "@/app/actions/tags";
import { cn } from "@/lib/utils";
type ParsedRow = BulkImportRow & {
status: "new" | "exists" | "error";
errorMsg?: string;
willCreateCategory?: boolean;
};
type Format = "json" | "csv" | "lines" | "empty";
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
function detectFormat(text: string): Format {
const t = text.trim();
if (!t) return "empty";
if (t.startsWith("[") || t.startsWith("{")) return "json";
// CSV if any line has a comma. Single-column CSV degrades to lines anyway.
if (t.split("\n").some((l) => l.includes(","))) return "csv";
return "lines";
}
function parseInput(text: string, format: Format): { rows: BulkImportRow[]; parseError?: string } {
if (format === "empty") return { rows: [] };
if (format === "json") {
try {
const parsed = JSON.parse(text);
const arr = Array.isArray(parsed) ? parsed : Array.isArray((parsed as { tags?: unknown }).tags) ? (parsed as { tags: unknown[] }).tags : null;
if (!arr) return { rows: [], parseError: "JSON must be an array (or object with `tags` array)." };
const rows: BulkImportRow[] = [];
for (const item of arr) {
if (typeof item === "string") {
rows.push({ name: item });
} else if (item && typeof item === "object") {
const obj = item as Record<string, unknown>;
rows.push({
name: String(obj.name ?? ""),
category: obj.category != null ? String(obj.category) : null,
color: obj.color != null ? String(obj.color) : null,
});
}
}
return { rows };
} catch (e) {
return { rows: [], parseError: `JSON parse error: ${(e as Error).message}` };
}
}
if (format === "csv") {
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return { rows: [] };
const first = splitCsvLine(lines[0]);
const looksLikeHeader = first.some((c) => /^(name|category|color)$/i.test(c));
let header: string[];
let dataLines: string[];
if (looksLikeHeader) {
header = first.map((c) => c.toLowerCase());
dataLines = lines.slice(1);
} else {
header = ["name", "category", "color"].slice(0, first.length);
dataLines = lines;
}
const nameIdx = header.indexOf("name");
const catIdx = header.indexOf("category");
const colorIdx = header.indexOf("color");
const rows: BulkImportRow[] = dataLines.map((line) => {
const cols = splitCsvLine(line);
return {
name: nameIdx >= 0 ? cols[nameIdx] ?? "" : cols[0] ?? "",
category: catIdx >= 0 ? cols[catIdx] ?? null : null,
color: colorIdx >= 0 ? cols[colorIdx] ?? null : null,
};
});
return { rows };
}
// lines
const rows: BulkImportRow[] = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
.map((name) => ({ name }));
return { rows };
}
function splitCsvLine(line: string): string[] {
// Minimal CSV splitter: respects double-quoted fields and "" escapes.
const out: string[] = [];
let cur = "";
let inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQ) {
if (ch === '"') {
if (line[i + 1] === '"') { cur += '"'; i++; }
else inQ = false;
} else cur += ch;
} else {
if (ch === ",") { out.push(cur.trim()); cur = ""; }
else if (ch === '"' && cur === "") inQ = true;
else cur += ch;
}
}
out.push(cur.trim());
return out;
}
export function TagImportModal({
existingTagNames,
existingCategoryNames,
onClose,
}: {
existingTagNames: Set<string>;
existingCategoryNames: Set<string>;
onClose: () => void;
}) {
const router = useRouter();
const [text, setText] = useState("");
const [createMissingCategories, setCreateMissingCategories] = useState(true);
const [updateExisting, setUpdateExisting] = useState(false);
const [pending, startTransition] = useTransition();
const [result, setResult] = useState<BulkImportResult | null>(null);
const format = useMemo(() => detectFormat(text), [text]);
const { rows, parseError } = useMemo(() => parseInput(text, format), [text, format]);
const preview = useMemo<ParsedRow[]>(() => {
return rows.map((r) => {
const name = (r.name ?? "").trim().toLowerCase();
if (!name) return { ...r, status: "error", errorMsg: "blank name" };
if (name.length > 48) return { ...r, status: "error", errorMsg: "name too long" };
if (r.color && !COLOR_RE.test(r.color.trim())) return { ...r, status: "error", errorMsg: "bad color" };
const willCreateCategory = !!r.category && !existingCategoryNames.has(r.category.trim().toLowerCase());
const status: ParsedRow["status"] = existingTagNames.has(name) ? "exists" : "new";
return { ...r, name, status, willCreateCategory };
});
}, [rows, existingTagNames, existingCategoryNames]);
const counts = useMemo(() => {
let n = 0, dup = 0, err = 0, cats = 0;
for (const p of preview) {
if (p.status === "new") n++;
else if (p.status === "exists") dup++;
else err++;
if (p.willCreateCategory) cats++;
}
return { n, dup, err, cats };
}, [preview]);
const willImport = updateExisting ? counts.n + counts.dup : counts.n;
const canSubmit = !pending && willImport > 0 && !parseError;
function submit() {
const valid = rows.filter((_, i) => preview[i].status !== "error");
if (valid.length === 0) return;
startTransition(async () => {
const r = await bulkImportTags(valid, { createMissingCategories, updateExisting });
setResult(r);
if (r.ok) router.refresh();
});
}
async function handleFile(file: File) {
const t = await file.text();
setText(t);
}
const FormatIcon = format === "json" ? FileCode : format === "csv" ? Table : FileText;
return createPortal(
<div className="fixed inset-0 z-[100] grid place-items-center bg-black/70 backdrop-blur-sm p-4" onClick={onClose}>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-[1000px] rounded-2xl bg-[var(--color-bg-1)] border border-[var(--color-glass-border-strong)] shadow-2xl overflow-hidden"
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--color-glass-border)]">
<div className="flex items-center gap-3">
<div className="text-base font-semibold">Import tags</div>
{format !== "empty" && (
<span className="inline-flex items-center gap-1 text-[11px] uppercase tracking-wider font-mono px-2 py-0.5 rounded-full border border-[var(--color-cyan)]/40 bg-[var(--color-cyan)]/10 text-[var(--color-cyan)]">
<FormatIcon className="w-3 h-3" /> {format}
</span>
)}
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--color-glass)]" aria-label="Close">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 grid grid-cols-2 gap-4">
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[11px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
Paste line, CSV, or JSON
</div>
<label className="text-[11px] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] cursor-pointer">
<input
type="file"
accept=".csv,.json,.txt,text/csv,application/json,text/plain"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
or upload file
</label>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={14}
placeholder={`Bondage\nCosplay\nBukkake\n\nor:\n\nname,category,color\nBondage,Fetish,#a78bfa\nCosplay,Genre,#22d3ee`}
className="w-full bg-[var(--color-bg-2)] border border-[var(--color-glass-border)] rounded-lg p-3 text-xs font-mono outline-none focus:border-[var(--color-cyan)] resize-none"
/>
<div className="mt-3 flex flex-col gap-1.5 text-xs">
<label className="inline-flex items-center gap-2">
<input type="checkbox" checked={createMissingCategories} onChange={(e) => setCreateMissingCategories(e.target.checked)} className="accent-[var(--color-cyan)]" />
Create missing categories
</label>
<label className="inline-flex items-center gap-2">
<input type="checkbox" checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)} className="accent-[var(--color-cyan)]" />
Update existing (color &amp; category)
</label>
</div>
</div>
<div className="min-h-0">
<div className="text-[11px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1.5">
Preview · {preview.length} parsed
</div>
<div className="rounded-lg border border-[var(--color-glass-border)] bg-[var(--color-bg-2)] overflow-hidden max-h-[340px] overflow-y-auto">
{parseError ? (
<div className="p-4 text-xs text-red-300">{parseError}</div>
) : preview.length === 0 ? (
<div className="p-4 text-xs text-[var(--color-fg-muted)]">Paste tags to see a preview.</div>
) : (
<table className="w-full text-xs">
<thead className="text-[10px] uppercase tracking-wider text-[var(--color-fg-muted)]">
<tr>
<th className="text-left px-3 py-2 font-medium">Name</th>
<th className="text-left px-3 py-2 font-medium">Category</th>
<th className="text-left px-3 py-2 font-medium">Color</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{preview.map((p, i) => (
<tr key={i} className="border-t border-[var(--color-glass-border)]">
<td className="px-3 py-1.5">{p.name || <span className="text-[var(--color-fg-muted)]"></span>}</td>
<td className="px-3 py-1.5">
{p.category ? (
<>
{p.category}
{p.willCreateCategory && (
<span className="ml-1.5 text-[10px] text-[var(--color-mint)]">+ create</span>
)}
</>
) : <span className="text-[var(--color-fg-muted)]"></span>}
</td>
<td className="px-3 py-1.5">
{p.color && COLOR_RE.test(p.color) ? (
<span className="inline-block w-3.5 h-3.5 rounded align-middle" style={{ background: p.color }} />
) : <span className="text-[var(--color-fg-muted)]"></span>}
</td>
<td className="px-3 py-1.5">
{p.status === "new" && <span className="text-[var(--color-mint)]">+ new</span>}
{p.status === "exists" && <span className="text-[var(--color-amber)]"> exists</span>}
{p.status === "error" && <span className="text-red-300"> {p.errorMsg}</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{preview.length > 0 && !parseError && (
<div className="mt-3 flex items-center gap-3 text-xs text-[var(--color-fg-dim)]">
<span><strong className="text-[var(--color-fg)]">{counts.n}</strong> new</span>
<span><strong className="text-[var(--color-fg)]">{counts.dup}</strong> dup</span>
{counts.err > 0 && <span className="text-red-300"><strong>{counts.err}</strong> error</span>}
{counts.cats > 0 && <span className="text-[var(--color-violet)]"><strong>{counts.cats}</strong> cat to create</span>}
</div>
)}
</div>
</div>
{result && (
<div className={cn(
"px-5 py-3 text-xs border-t border-[var(--color-glass-border)]",
result.ok ? "text-[var(--color-mint)] bg-[var(--color-mint)]/5" : "text-red-300 bg-red-500/5",
)}>
{result.ok ? (
<>Imported. <strong>{result.added}</strong> added, <strong>{result.updated}</strong> updated, <strong>{result.skipped}</strong> skipped, <strong>{result.categoriesCreated}</strong> categories created.{result.errors.length > 0 && ` ${result.errors.length} row error(s).`}</>
) : (
<>Failed: {result.errors[0]?.message ?? "unknown error"}</>
)}
</div>
)}
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--color-glass-border)] bg-[var(--color-glass)]">
<div className="text-[11px] text-[var(--color-fg-muted)]">
Tip: drag a <code className="px-1 py-0.5 bg-[var(--color-bg-2)] rounded text-[10px]">.csv</code> / <code className="px-1 py-0.5 bg-[var(--color-bg-2)] rounded text-[10px]">.json</code> file onto the textarea.
</div>
<div className="flex items-center gap-2">
<button onClick={onClose} className="text-sm px-3 py-1.5 rounded-lg glass glass-hover">
{result?.ok ? "Done" : "Cancel"}
</button>
<button
onClick={submit}
disabled={!canSubmit}
className="inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black disabled:opacity-50 disabled:cursor-not-allowed"
>
{pending && <Loader2 className="w-4 h-4 animate-spin" />}
{pending ? "Importing…" : `Import ${willImport} tag${willImport === 1 ? "" : "s"}`}
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useEffect, useMemo, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, Trash2, X, Loader2 } from "lucide-react";
import { bulkDeleteTags } from "@/app/actions/tags";
import { cn } from "@/lib/utils";
export type TagListItem = {
id: number;
name: string;
count: number;
categoryId: number | null;
categoryName: string | null;
categoryColor: string | null;
};
export function TagsList({ tags, sort }: { tags: TagListItem[]; sort: "az" | "count" }) {
const router = useRouter();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [pending, start] = useTransition();
const anySelected = selected.size > 0;
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
function toggle(id: number) {
setSelected((cur) => {
const next = new Set(cur);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function selectAll() {
setSelected(new Set(tags.map((t) => t.id)));
}
function clear() {
setSelected(new Set());
}
function deleteSelected() {
if (selected.size === 0) return;
const totalCovers = tags.filter((t) => selected.has(t.id)).reduce((acc, t) => acc + t.count, 0);
const msg = `Delete ${selected.size} tag${selected.size === 1 ? "" : "s"}?\n\n` +
(totalCovers > 0
? `This will also remove ${totalCovers} cover-tag association${totalCovers === 1 ? "" : "s"}. Covers themselves are NOT deleted.`
: "These tags are unused.") +
"\n\nThis cannot be undone.";
if (!confirm(msg)) return;
const ids = Array.from(selected);
start(async () => {
await bulkDeleteTags(ids);
setSelected(new Set());
router.refresh();
});
}
// Group tags by category for rendering. Uncategorised pinned last.
const groups = useMemo(() => {
const m = new Map<number | null, { name: string; color: string | null; tags: TagListItem[] }>();
for (const t of tags) {
const key = t.categoryId;
if (!m.has(key)) {
m.set(key, { name: t.categoryName ?? "Uncategorised", color: t.categoryColor, tags: [] });
}
m.get(key)!.tags.push(t);
}
return Array.from(m.entries()).sort(([keyA, a], [keyB, b]) => {
if (keyA === null) return 1;
if (keyB === null) return -1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}, [tags]);
return (
<div className="space-y-5">
{groups.map(([key, g]) => {
const groupAllSelected = g.tags.every((t) => selected.has(t.id));
function toggleGroup() {
setSelected((cur) => {
const next = new Set(cur);
if (groupAllSelected) {
for (const t of g.tags) next.delete(t.id);
} else {
for (const t of g.tags) next.add(t.id);
}
return next;
});
}
return (
<section key={key ?? "none"}>
<div className="flex items-center gap-2 mb-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: g.color ?? "var(--color-fg-muted)" }}
/>
<h2 className="text-xs uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
{g.name}
</h2>
<span className="text-[10px] font-mono text-[var(--color-fg-muted)]">({g.tags.length})</span>
<button
type="button"
onClick={toggleGroup}
className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] hover:text-[var(--color-cyan)] ml-1"
>
{groupAllSelected ? "deselect all" : "select all"}
</button>
</div>
<div className="flex flex-wrap gap-2">
{g.tags.map((t) => {
const isSelected = selected.has(t.id);
return (
<div key={t.id} className="relative group">
<Link
href={`/tag/${encodeURIComponent(t.name)}`}
onClick={(e) => {
if (anySelected) {
e.preventDefault();
toggle(t.id);
}
}}
className={cn(
"flex items-center gap-2 pl-3 pr-3 py-1.5 rounded-full glass glass-hover text-sm transition-all",
isSelected && "ring-2 ring-[var(--color-cyan)] bg-[var(--color-cyan)]/10",
anySelected && !isSelected && "opacity-60 hover:opacity-100",
)}
>
<span className={cn(isSelected ? "text-[var(--color-cyan)]" : "text-[var(--color-violet)]")}>{t.name}</span>
<span className="text-xs font-mono text-[var(--color-fg-muted)]">{t.count}</span>
</Link>
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggle(t.id); }}
aria-label={isSelected ? "Deselect" : "Select"}
className={cn(
"absolute -top-1.5 -right-1.5 w-5 h-5 grid place-items-center rounded-full border-2 transition-all",
isSelected
? "bg-[var(--color-cyan)] border-[var(--color-cyan)] text-black"
: "bg-[var(--color-bg-1)] border-[var(--color-glass-border-strong)] text-transparent",
!isSelected && !anySelected && "opacity-0 group-hover:opacity-100",
)}
>
<Check className="w-3 h-3" strokeWidth={3} />
</button>
</div>
);
})}
</div>
</section>
);
})}
<p className="text-[11px] text-[var(--color-fg-muted)] pt-2 italic">
Sort {sort === "az" ? "A-Z" : "by count"} applies inside each category.
</p>
{anySelected && mounted && createPortal(
<div className="fixed bottom-[12px] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 px-4 py-2 rounded-full bg-[var(--color-bg-1)] border border-[var(--color-glass-border-strong)] shadow-2xl backdrop-blur-xl">
<span className="text-sm font-mono">
<strong className="text-[var(--color-cyan)]">{selected.size}</strong> selected
</span>
<span className="w-px h-5 bg-[var(--color-glass-border)]" />
<button
onClick={selectAll}
className="text-xs px-2.5 py-1 rounded hover:bg-[var(--color-glass)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
Select all ({tags.length})
</button>
<button
onClick={deleteSelected}
disabled={pending}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1 rounded text-red-300 border border-red-500/40 hover:bg-red-500/10 disabled:opacity-50"
>
{pending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
Delete
</button>
<button
onClick={clear}
className="p-1 rounded hover:bg-[var(--color-glass)] text-[var(--color-fg-dim)]"
aria-label="Clear selection"
>
<X className="w-4 h-4" />
</button>
</div>,
document.body,
)}
</div>
);
}