Initial commit
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { rawDb } from "@/lib/db/client";
|
||||
import { listImages, listAllTagCategories } from "@/lib/db/queries";
|
||||
import { MasonryGrid } from "@/components/grid/MasonryGrid";
|
||||
import { RegisterVisible } from "@/components/select/RegisterVisible";
|
||||
import { FilterBar } from "@/components/grid/FilterBar";
|
||||
import { UploadCard } from "@/components/ingest/UploadCard";
|
||||
import { resolveSort } from "@/lib/sortServer";
|
||||
import { deleteTag, renameTag } from "@/app/actions/entities";
|
||||
import { EntityRenameInline } from "@/components/entities/EntityRenameInline";
|
||||
import { TagCategoryPicker } from "@/components/categories/TagCategoryPicker";
|
||||
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TagPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ name: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { name } = await params;
|
||||
const sp = await searchParams;
|
||||
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
|
||||
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
|
||||
const criteria = parseFilterCriteria(sp);
|
||||
const decoded = decodeURIComponent(name);
|
||||
const tag = rawDb.prepare(`SELECT id, category_id FROM tags WHERE name = ?`).get(decoded) as
|
||||
| { id: number; category_id: number | null }
|
||||
| undefined;
|
||||
if (!tag) notFound();
|
||||
const categories = listAllTagCategories();
|
||||
const items = listImages({
|
||||
tagId: tag.id,
|
||||
sort,
|
||||
search,
|
||||
...statusToFlags(criteria.status),
|
||||
marks: criteria.marks,
|
||||
actressIds: criteria.ids.actresses,
|
||||
actressMode: criteria.mode.actresses,
|
||||
studioIds: criteria.ids.studios,
|
||||
seriesIds: criteria.ids.series,
|
||||
genreIds: criteria.ids.genres,
|
||||
genreMode: criteria.mode.genres,
|
||||
collectionIds: criteria.ids.collections,
|
||||
collectionMode: criteria.mode.collections,
|
||||
tagIds: criteria.ids.tags,
|
||||
tagMode: criteria.mode.tags,
|
||||
categoryIds: criteria.ids.categories,
|
||||
categoryMode: criteria.mode.categories,
|
||||
});
|
||||
|
||||
const remove = async () => {
|
||||
"use server";
|
||||
await deleteTag(decoded);
|
||||
};
|
||||
|
||||
const rename = async (name: string) => {
|
||||
"use server";
|
||||
return await renameTag(decoded, name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
|
||||
<Link href="/tag" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] mb-4">
|
||||
<ArrowLeft className="w-4 h-4" /> All tags
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-6 mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-semibold tracking-tight mb-1">
|
||||
<span className="text-[var(--color-violet)]">#{decoded}</span>
|
||||
</h1>
|
||||
<EntityRenameInline
|
||||
initialName={decoded}
|
||||
onRename={rename}
|
||||
redirectBase="/tag/"
|
||||
redirectKey="name"
|
||||
/>
|
||||
<form action={remove}>
|
||||
<button className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg glass text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] hover:border-[var(--color-coral)]/30">
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p className="text-[var(--color-fg-dim)]">{items.length} image{items.length === 1 ? "" : "s"}</p>
|
||||
<div className="mt-3">
|
||||
<TagCategoryPicker
|
||||
tagId={tag.id}
|
||||
currentCategoryId={tag.category_id}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name, slug: c.slug, color: c.color }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[300px] flex-shrink-0">
|
||||
<UploadCard autoAssign={{ tagName: decoded }} />
|
||||
</div>
|
||||
</div>
|
||||
<FilterBar current={{ kind: "tag", name: decoded }} criteria={criteria} sort={sort} />
|
||||
<RegisterVisible ids={items.map((i) => i.id)} />
|
||||
<MasonryGrid images={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Link from "next/link";
|
||||
import { listAllTags, listAllTagCategories, type TagSort } from "@/lib/db/queries";
|
||||
import { createTagAction } from "@/app/actions/tags";
|
||||
import { Tag, Plus, ArrowDownAZ, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TagImportButton } from "@/components/tag/TagImportButton";
|
||||
import { TagsList } from "@/components/tag/TagsList";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function TagsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const sort: TagSort = sp.sort === "count" ? "count" : "az";
|
||||
const tags = listAllTags(sort);
|
||||
const categories = listAllTagCategories("az");
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Tags</h1>
|
||||
<p className="text-[var(--color-fg-dim)] mt-1">{tags.length} total</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
|
||||
<Link
|
||||
href="/tag"
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5",
|
||||
sort === "az"
|
||||
? "bg-[var(--color-cyan)] text-black font-medium"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
title="Sort A → Z"
|
||||
>
|
||||
<ArrowDownAZ className="w-3.5 h-3.5" /> A-Z
|
||||
</Link>
|
||||
<Link
|
||||
href="/tag?sort=count"
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5",
|
||||
sort === "count"
|
||||
? "bg-[var(--color-cyan)] text-black font-medium"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
title="Sort by image count"
|
||||
>
|
||||
<Hash className="w-3.5 h-3.5" /> Count
|
||||
</Link>
|
||||
</div>
|
||||
<TagImportButton
|
||||
existingTagNames={tags.map((t) => t.name)}
|
||||
existingCategoryNames={categories.map((c) => c.name)}
|
||||
/>
|
||||
<form action={createTagAction} className="flex items-center gap-2">
|
||||
<input
|
||||
name="name"
|
||||
placeholder="New tag"
|
||||
required
|
||||
maxLength={48}
|
||||
className="glass rounded-lg px-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
|
||||
/>
|
||||
<button className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium">
|
||||
<Plus className="w-4 h-4" /> Create
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-card text-center">
|
||||
<Tag className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
|
||||
<p className="text-[var(--color-fg-dim)]">No tags yet. Create one above or add from any image.</p>
|
||||
</div>
|
||||
) : (
|
||||
<TagsList tags={tags.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
count: t.count,
|
||||
categoryId: t.categoryId,
|
||||
categoryName: t.categoryName,
|
||||
categoryColor: t.categoryColor,
|
||||
}))} sort={sort} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user