Initial commit
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user