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
+107
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}