Initial commit
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { getTagCategoryBySlug, listTagsInCategory, listAllTags } from "@/lib/db/queries";
|
||||
import { deleteTagCategory, renameTagCategory } from "@/app/actions/tagCategories";
|
||||
import { EntityRenameInline } from "@/components/entities/EntityRenameInline";
|
||||
import { CategoryTagAssigner } from "@/components/categories/CategoryTagAssigner";
|
||||
import { CategoryCoverPanel } from "@/components/categories/CategoryCoverPanel";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CategoryDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const cat = getTagCategoryBySlug(decodeURIComponent(slug));
|
||||
if (!cat) notFound();
|
||||
const tags = listTagsInCategory(cat.id);
|
||||
// All tags, with their current category, so the assigner can let the
|
||||
// user reassign uncategorised or differently-categorised tags into
|
||||
// this one.
|
||||
const allTags = listAllTags("az");
|
||||
|
||||
const remove = async () => {
|
||||
"use server";
|
||||
await deleteTagCategory(cat.id);
|
||||
};
|
||||
const rename = async (name: string) => {
|
||||
"use server";
|
||||
return await renameTagCategory(cat.id, name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
|
||||
<Link href="/category" 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 categories
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-6 mb-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-4 h-4 rounded-full shrink-0"
|
||||
style={{ background: cat.color ?? "var(--color-fg-muted)" }}
|
||||
/>
|
||||
<h1 className="text-3xl font-semibold tracking-tight truncate">{cat.name}</h1>
|
||||
<EntityRenameInline
|
||||
initialName={cat.name}
|
||||
onRename={rename}
|
||||
redirectBase="/category/"
|
||||
redirectKey="slug"
|
||||
/>
|
||||
<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>
|
||||
{cat.description && <p className="text-[var(--color-fg-dim)] mt-2">{cat.description}</p>}
|
||||
<p className="text-sm text-[var(--color-fg-muted)] mt-1">{tags.length} tag{tags.length === 1 ? "" : "s"} in this category</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">Cover art</h2>
|
||||
<CategoryCoverPanel
|
||||
categoryId={cat.id}
|
||||
categoryName={cat.name}
|
||||
categoryColor={cat.color}
|
||||
portrait={{
|
||||
path: cat.coverPortraitPath,
|
||||
zoom: cat.coverPortraitZoom,
|
||||
offsetX: cat.coverPortraitOffsetX,
|
||||
offsetY: cat.coverPortraitOffsetY,
|
||||
}}
|
||||
landscape={{
|
||||
path: cat.coverLandscapePath,
|
||||
zoom: cat.coverLandscapeZoom,
|
||||
offsetX: cat.coverLandscapeOffsetX,
|
||||
offsetY: cat.coverLandscapeOffsetY,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">Member tags</h2>
|
||||
{tags.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-8 text-center text-sm text-[var(--color-fg-dim)]">
|
||||
No tags assigned yet. Use the picker below to add some.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
href={`/tag/${encodeURIComponent(t.name)}`}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full glass glass-hover text-sm"
|
||||
>
|
||||
<span className="text-[var(--color-violet)]">{t.name}</span>
|
||||
<span className="text-xs font-mono text-[var(--color-fg-muted)]">{t.count}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">Assign tags</h2>
|
||||
<CategoryTagAssigner
|
||||
categoryId={cat.id}
|
||||
tags={allTags.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
count: t.count,
|
||||
currentCategoryId: t.categoryId,
|
||||
currentCategoryName: t.categoryName,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Link from "next/link";
|
||||
import { Layers, Plus, ArrowDownAZ, Hash, RectangleVertical, RectangleHorizontal } from "lucide-react";
|
||||
import { listAllTagCategories, type CategorySort } from "@/lib/db/queries";
|
||||
import { createTagCategoryAction } from "@/app/actions/tagCategories";
|
||||
import { CategoryGridCard } from "@/components/categories/CategoryGridCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type View = "portrait" | "landscape";
|
||||
|
||||
export default async function CategoriesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const sort: CategorySort = sp.sort === "count" ? "count" : "az";
|
||||
const view: View = sp.view === "landscape" ? "landscape" : "portrait";
|
||||
const qs = (overrides: Partial<{ sort: CategorySort; view: View }>) => {
|
||||
const params = new URLSearchParams();
|
||||
const finalSort = overrides.sort ?? sort;
|
||||
const finalView = overrides.view ?? view;
|
||||
if (finalSort === "count") params.set("sort", "count");
|
||||
if (finalView === "landscape") params.set("view", "landscape");
|
||||
const s = params.toString();
|
||||
return s ? `/category?${s}` : "/category";
|
||||
};
|
||||
const cats = listAllTagCategories(sort);
|
||||
return (
|
||||
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
|
||||
<div className="flex items-start justify-between gap-6 mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Categories</h1>
|
||||
<p className="text-[var(--color-fg-dim)] mt-1 max-w-prose">
|
||||
Umbrellas that group related tags. A category like <span className="font-mono text-[var(--color-fg)]">BDSM</span> can collect{" "}
|
||||
<span className="font-mono text-[var(--color-fg)]">bondage</span>,{" "}
|
||||
<span className="font-mono text-[var(--color-fg)]">shibari</span>,{" "}
|
||||
<span className="font-mono text-[var(--color-fg)]">cuffs</span>, etc.
|
||||
Each tag belongs to at most one category.
|
||||
</p>
|
||||
<p className="text-sm text-[var(--color-fg-muted)] mt-1">{cats.length} categor{cats.length === 1 ? "y" : "ies"}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
|
||||
<Link
|
||||
href={qs({ sort: "az" })}
|
||||
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={qs({ 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 cover count"
|
||||
>
|
||||
<Hash className="w-3.5 h-3.5" /> Count
|
||||
</Link>
|
||||
</div>
|
||||
<form action={createTagCategoryAction} className="flex items-center gap-2">
|
||||
<input
|
||||
name="name"
|
||||
placeholder="New category"
|
||||
required
|
||||
maxLength={64}
|
||||
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 className="flex items-center rounded-lg glass overflow-hidden text-xs">
|
||||
<Link
|
||||
href={qs({ view: "portrait" })}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5",
|
||||
view === "portrait"
|
||||
? "bg-[var(--color-cyan)] text-black font-medium"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
title="Portrait layout"
|
||||
>
|
||||
<RectangleVertical className="w-3.5 h-3.5" /> P
|
||||
</Link>
|
||||
<Link
|
||||
href={qs({ view: "landscape" })}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5",
|
||||
view === "landscape"
|
||||
? "bg-[var(--color-cyan)] text-black font-medium"
|
||||
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
title="Landscape layout"
|
||||
>
|
||||
<RectangleHorizontal className="w-3.5 h-3.5" /> L
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-card text-center">
|
||||
<Layers className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
|
||||
<p className="text-[var(--color-fg-dim)]">No categories yet. Create one above to start grouping tags.</p>
|
||||
</div>
|
||||
) : view === "portrait" ? (
|
||||
// Target 7 per row at full desktop width; scale down responsively.
|
||||
<div key="portrait" className="fade-in grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
|
||||
{cats.map((c) => (
|
||||
<CategoryGridCard
|
||||
key={c.id}
|
||||
id={c.id}
|
||||
slug={c.slug}
|
||||
name={c.name}
|
||||
color={c.color}
|
||||
description={c.description}
|
||||
tagCount={c.tagCount}
|
||||
imageCount={c.imageCount}
|
||||
view="portrait"
|
||||
portrait={{ path: c.coverPortraitPath, zoom: c.coverPortraitZoom, offsetX: c.coverPortraitOffsetX, offsetY: c.coverPortraitOffsetY }}
|
||||
landscape={{ path: c.coverLandscapePath, zoom: c.coverLandscapeZoom, offsetX: c.coverLandscapeOffsetX, offsetY: c.coverLandscapeOffsetY }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Landscape: 3 per row at desktop; 1-2 on smaller screens.
|
||||
<div key="landscape" className="fade-in grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{cats.map((c) => (
|
||||
<CategoryGridCard
|
||||
key={c.id}
|
||||
id={c.id}
|
||||
slug={c.slug}
|
||||
name={c.name}
|
||||
color={c.color}
|
||||
description={c.description}
|
||||
tagCount={c.tagCount}
|
||||
imageCount={c.imageCount}
|
||||
view="landscape"
|
||||
portrait={{ path: c.coverPortraitPath, zoom: c.coverPortraitZoom, offsetX: c.coverPortraitOffsetX, offsetY: c.coverPortraitOffsetY }}
|
||||
landscape={{ path: c.coverLandscapePath, zoom: c.coverLandscapeZoom, offsetX: c.coverLandscapeOffsetX, offsetY: c.coverLandscapeOffsetY }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user