Add favorites/library app with Coolify deployment setup

Personal favorites tracker: Next.js 16 App Router, Prisma/PostgreSQL,
NextAuth v5 credentials auth with admin/friend roles, category-driven
custom fields, lending library, optional S3 cover images.

Deployment: multi-stage Dockerfile (standalone output) whose entrypoint
runs prisma migrate deploy and the idempotent seed on every boot; env
validation made lazy so next build works in the secret-free image.
See DEPLOY.md for Coolify setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Greg 2026-07-12 12:20:41 +02:00
parent e60935b76e
commit aa0d14b679
64 changed files with 13633 additions and 81 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
.next
.git
.env*
Dockerfile
.dockerignore
coverage
*.tsbuildinfo

3
.gitignore vendored
View File

@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# local claude settings
.claude/settings.local.json

50
DEPLOY.md Normal file
View File

@ -0,0 +1,50 @@
# Deploying on Coolify
The repo ships a multi-stage `Dockerfile`. On every container start,
`docker-entrypoint.sh` applies pending Prisma migrations, runs the idempotent
seed (default categories + admin bootstrap), then starts the standalone
Next.js server on port 3000.
## One-time setup
1. **Database** — in Coolify, create a **PostgreSQL** resource. Copy its
*internal* connection URL (the app and DB talk over Coolify's internal
network).
2. **Application** — create an application from this git repository.
- Build pack: **Dockerfile**
- Port: **3000**
3. **Environment variables** (set in Coolify, mark secrets as such):
| Variable | Value |
| --- | --- |
| `DATABASE_URL` | internal Postgres URL from step 1 |
| `AUTH_SECRET` | generate with `openssl rand -base64 32` |
| `AUTH_TRUST_HOST` | `true` (required behind Coolify's reverse proxy) |
| `ADMIN_EMAIL` | login email for the first admin user |
| `ADMIN_PASSWORD` | initial admin password (change after first login) |
Optional: the `S3_*` variables enable cover-image uploads (any
S3-compatible store, e.g. Cloudflare R2 or a Coolify-hosted MinIO);
`TMDB_API_KEY`, `GOOGLE_BOOKS_API_KEY`, `SPOTIFY_CLIENT_ID/SECRET`,
`GOOGLE_PLACES_API_KEY`, `YELP_API_KEY` enable metadata lookups.
`NEXTAUTH_URL` is not needed — `AUTH_TRUST_HOST=true` derives the URL
from the request.
4. **Domain** — assign your domain/HTTPS in Coolify and deploy.
The admin user is only created if no admin exists yet, and the seed never
overwrites categories you've edited, so redeploys are safe.
## Health check (optional)
Unauthenticated requests to `/` redirect to `/login`, so point Coolify's
health check at `/login` (expects HTTP 200).
## Local production image test
```bash
docker build -t mystuff2 .
docker run --rm -p 3000:3000 \
-e DATABASE_URL=... -e AUTH_SECRET=... -e AUTH_TRUST_HOST=true \
-e ADMIN_EMAIL=... -e ADMIN_PASSWORD=... mystuff2
```

39
Dockerfile Normal file
View File

@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build
# Prisma CLI + tsx for the startup migrate/seed step, isolated in their own
# tree so they don't interfere with the traced standalone node_modules.
FROM node:22-alpine AS tooling
WORKDIR /tooling
# bcryptjs is bundled into the server chunks by the standalone build, so the
# seed script can't resolve it from the app tree — provide it here instead.
RUN npm install --no-package-lock prisma@6 tsx@4 bcryptjs@3
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN apk add --no-cache openssl && \
addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=tooling --chown=nextjs:nodejs /tooling ./tooling
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./
USER nextjs
EXPOSE 3000
CMD ["sh", "./docker-entrypoint.sh"]

14
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,14 @@
#!/bin/sh
set -e
: "${DATABASE_URL:?DATABASE_URL must be set}"
: "${AUTH_SECRET:?AUTH_SECRET must be set}"
echo "Applying database migrations..."
./tooling/node_modules/.bin/prisma migrate deploy --schema prisma/schema.prisma
echo "Seeding defaults (idempotent)..."
NODE_PATH=/app/tooling/node_modules ./tooling/node_modules/.bin/tsx prisma/seed.ts
echo "Starting Next.js..."
exec node server.js

View File

@ -1,7 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// A stray lockfile exists in the parent directory; pin the workspace root.
turbopack: { root: __dirname },
// Minimal self-contained server bundle for the Docker image (see Dockerfile).
output: "standalone",
};
export default nextConfig;

9009
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,21 +6,42 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.2",
"@aws-sdk/client-s3": "^3.1080.0",
"@prisma/client": "^6.19.3",
"@tiptap/extension-image": "^3.27.2",
"@tiptap/extension-link": "^3.27.2",
"@tiptap/react": "^3.27.2",
"@tiptap/starter-kit": "^3.27.2",
"bcryptjs": "^3.0.3",
"date-fns": "^4.4.0",
"lucide-react": "^1.23.0",
"next": "16.2.10",
"next-auth": "^5.0.0-beta.31",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"sanitize-html": "^2.17.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"prisma": "^6.19.3",
"tailwindcss": "^4",
"tsx": "^4.23.0",
"typescript": "^5"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

View File

@ -0,0 +1,175 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'FRIEND');
-- CreateEnum
CREATE TYPE "LoanStatus" AS ENUM ('AVAILABLE', 'LENT_OUT');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT,
"role" "Role" NOT NULL DEFAULT 'FRIEND',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"icon" TEXT,
"isShared" BOOLEAN NOT NULL DEFAULT false,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"fieldSchema" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Item" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"coverImageKey" TEXT,
"categoryId" TEXT NOT NULL,
"rating" INTEGER,
"description" TEXT,
"notesHtml" TEXT,
"customFields" JSONB NOT NULL DEFAULT '{}',
"dateAdded" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ItemTag" (
"itemId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
CONSTRAINT "ItemTag_pkey" PRIMARY KEY ("itemId","tagId")
);
-- CreateTable
CREATE TABLE "LibraryItem" (
"id" TEXT NOT NULL,
"itemId" TEXT,
"standaloneTitle" TEXT,
"mediaType" TEXT,
"condition" TEXT,
"loanStatus" "LoanStatus" NOT NULL DEFAULT 'AVAILABLE',
"borrowerName" TEXT,
"dateLent" TIMESTAMP(3),
"expectedReturnDate" TIMESTAMP(3),
"notes" TEXT,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LibraryItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Category_key_key" ON "Category"("key");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
-- CreateIndex
CREATE INDEX "Item_categoryId_idx" ON "Item"("categoryId");
-- CreateIndex
CREATE INDEX "Item_rating_idx" ON "Item"("rating");
-- CreateIndex
CREATE INDEX "LibraryItem_loanStatus_idx" ON "LibraryItem"("loanStatus");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemTag" ADD CONSTRAINT "ItemTag_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemTag" ADD CONSTRAINT "ItemTag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LibraryItem" ADD CONSTRAINT "LibraryItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LibraryItem" ADD CONSTRAINT "LibraryItem_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

148
prisma/schema.prisma Normal file
View File

@ -0,0 +1,148 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
FRIEND
}
enum LoanStatus {
AVAILABLE
LENT_OUT
}
// --- Auth.js models (database session strategy) ---
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
role Role @default(FRIEND)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
items Item[] @relation("ItemOwner")
libraryItems LibraryItem[] @relation("LibraryOwner")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// --- Domain models ---
model Category {
id String @id @default(cuid())
key String @unique // stable slug used in code/URLs, e.g. "movies"
label String
icon String? // lucide icon name
isShared Boolean @default(false)
sortOrder Int @default(0)
fieldSchema Json // array of {key,label,type,required?,options?}
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items Item[]
}
model Tag {
id String @id @default(cuid())
name String @unique
itemTags ItemTag[]
}
model Item {
id String @id @default(cuid())
title String
coverImageKey String? // S3 object key; public URL built at render time
categoryId String
rating Int? // 1-5, null = unrated
description String? // short plain-text blurb for cards
notesHtml String? @db.Text // sanitized Tiptap HTML
customFields Json @default("{}")
dateAdded DateTime @default(now())
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
category Category @relation(fields: [categoryId], references: [id])
owner User @relation("ItemOwner", fields: [ownerId], references: [id])
itemTags ItemTag[]
libraryItems LibraryItem[]
@@index([categoryId])
@@index([rating])
}
model ItemTag {
itemId String
tagId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([itemId, tagId])
}
model LibraryItem {
id String @id @default(cuid())
itemId String? // optional link to a favorites Item
standaloneTitle String? // used when itemId is null
mediaType String? // "Book", "DVD", "Blu-ray", "Vinyl", ...
condition String?
loanStatus LoanStatus @default(AVAILABLE)
borrowerName String?
dateLent DateTime?
expectedReturnDate DateTime?
notes String? @db.Text
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
owner User @relation("LibraryOwner", fields: [ownerId], references: [id])
@@index([loanStatus])
}

162
prisma/seed.ts Normal file
View File

@ -0,0 +1,162 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
type FieldDef = {
key: string;
label: string;
type: "text" | "textarea" | "number" | "date" | "url" | "select";
options?: string[];
};
const CATEGORIES: Array<{
key: string;
label: string;
icon: string;
sortOrder: number;
fieldSchema: FieldDef[];
}> = [
{
key: "books",
label: "Books",
icon: "BookOpen",
sortOrder: 1,
fieldSchema: [
{ key: "author", label: "Author", type: "text" },
{ key: "isbn", label: "ISBN", type: "text" },
{ key: "publishedYear", label: "Published year", type: "number" },
{ key: "pageCount", label: "Pages", type: "number" },
],
},
{
key: "movies",
label: "Movies",
icon: "Clapperboard",
sortOrder: 2,
fieldSchema: [
{ key: "director", label: "Director", type: "text" },
{ key: "runtimeMinutes", label: "Runtime (min)", type: "number" },
{ key: "releaseYear", label: "Release year", type: "number" },
],
},
{
key: "tv-series",
label: "TV Series",
icon: "Tv",
sortOrder: 3,
fieldSchema: [
{ key: "creator", label: "Creator", type: "text" },
{ key: "seasons", label: "Seasons", type: "number" },
{ key: "firstAirYear", label: "First aired", type: "number" },
],
},
{
key: "recipes",
label: "Recipes",
icon: "ChefHat",
sortOrder: 4,
fieldSchema: [
{ key: "sourceUrl", label: "Source URL", type: "url" },
{ key: "prepTimeMinutes", label: "Prep time (min)", type: "number" },
{ key: "servings", label: "Servings", type: "number" },
],
},
{
key: "youtube",
label: "YouTube",
icon: "Youtube",
sortOrder: 5,
fieldSchema: [
{ key: "videoUrl", label: "Video URL", type: "url" },
{ key: "channel", label: "Channel", type: "text" },
],
},
{
key: "music",
label: "Music",
icon: "Music",
sortOrder: 6,
fieldSchema: [
{ key: "artist", label: "Artist", type: "text" },
{ key: "album", label: "Album", type: "text" },
{ key: "releaseYear", label: "Release year", type: "number" },
],
},
{
key: "concerts",
label: "Concerts",
icon: "Mic2",
sortOrder: 7,
fieldSchema: [
{ key: "artist", label: "Artist", type: "text" },
{ key: "venue", label: "Venue", type: "text" },
{ key: "eventDate", label: "Date", type: "date" },
],
},
{
key: "restaurants",
label: "Restaurants",
icon: "UtensilsCrossed",
sortOrder: 8,
fieldSchema: [
{ key: "location", label: "Location", type: "text" },
{ key: "cuisine", label: "Cuisine", type: "text" },
{ key: "website", label: "Website", type: "url" },
],
},
{
key: "articles",
label: "Articles & Sites",
icon: "Newspaper",
sortOrder: 9,
fieldSchema: [
{ key: "url", label: "URL", type: "url" },
{ key: "siteName", label: "Site", type: "text" },
],
},
];
async function main() {
for (const cat of CATEGORIES) {
await prisma.category.upsert({
where: { key: cat.key },
update: {}, // never overwrite admin's edits (label, isShared, fieldSchema)
create: cat,
});
}
console.log(`Seeded ${CATEGORIES.length} categories (existing rows untouched).`);
const adminExists = await prisma.user.findFirst({ where: { role: "ADMIN" } });
if (adminExists) {
console.log("Admin user already exists — skipping bootstrap.");
return;
}
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) {
console.warn(
"No admin user exists and ADMIN_EMAIL/ADMIN_PASSWORD are not set — skipping admin bootstrap."
);
return;
}
await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: "Admin",
role: "ADMIN",
},
});
console.log(`Created admin user ${email}.`);
}
main()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

View File

@ -0,0 +1,32 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import { parseFieldSchema } from "@/lib/field-schema";
import { CategoryForm } from "@/components/admin/CategoryForm";
export default async function EditCategoryPage({
params,
}: PageProps<"/admin/categories/[id]/edit">) {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const { id } = await params;
const category = await prisma.category.findUnique({ where: { id } });
if (!category) notFound();
return (
<div>
<h1 className="mb-6 text-2xl font-semibold">Edit {category.label}</h1>
<CategoryForm
category={{
id: category.id,
label: category.label,
key: category.key,
icon: category.icon,
isShared: category.isShared,
fields: parseFieldSchema(category.fieldSchema),
}}
/>
</div>
);
}

View File

@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { getViewerContext } from "@/server/db/visibility";
import { CategoryForm } from "@/components/admin/CategoryForm";
export default async function NewCategoryPage() {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
return (
<div>
<h1 className="mb-6 text-2xl font-semibold">New category</h1>
<CategoryForm />
</div>
);
}

View File

@ -0,0 +1,135 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { ArrowDown, ArrowUp, Pencil, Plus, Users } from "lucide-react";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import {
moveCategoryAction,
toggleCategorySharedAction,
} from "@/server/actions/category.actions";
import { CategoryIcon } from "@/components/nav/CategoryIcon";
import { DeleteCategoryButton } from "@/components/admin/DeleteCategoryButton";
export default async function CategoriesAdminPage() {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const categories = await prisma.category.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: { _count: { select: { items: true } } },
});
return (
<div className="max-w-3xl">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Categories</h1>
<Link
href="/admin/categories/new"
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-900 px-3 py-2 text-sm font-medium text-white hover:bg-zinc-700"
>
<Plus className="h-4 w-4" /> New category
</Link>
</div>
<div className="overflow-hidden rounded-xl border border-zinc-200">
<table className="w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500">
<tr>
<th className="px-4 py-2.5 font-medium">Category</th>
<th className="px-4 py-2.5 font-medium">Items</th>
<th className="px-4 py-2.5 font-medium">Visibility</th>
<th className="px-4 py-2.5 font-medium">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{categories.map((category, index) => (
<tr key={category.id} className="align-middle">
<td className="px-4 py-3">
<span className="flex items-center gap-2.5 font-medium">
<CategoryIcon
name={category.icon}
className="h-4 w-4 text-zinc-400"
/>
{category.label}
<code className="text-xs font-normal text-zinc-400">
/{category.key}
</code>
</span>
</td>
<td className="px-4 py-3 text-zinc-600">{category._count.items}</td>
<td className="px-4 py-3">
<form action={toggleCategorySharedAction}>
<input type="hidden" name="categoryId" value={category.id} />
<button
type="submit"
title={
category.isShared
? "Click to make private"
: "Click to share with friends"
}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition ${
category.isShared
? "bg-emerald-100 text-emerald-800 hover:bg-emerald-200"
: "bg-zinc-100 text-zinc-500 hover:bg-zinc-200"
}`}
>
<Users className="h-3.5 w-3.5" />
{category.isShared ? "Shared" : "Private"}
</button>
</form>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-0.5">
<form action={moveCategoryAction}>
<input type="hidden" name="categoryId" value={category.id} />
<input type="hidden" name="direction" value="up" />
<button
type="submit"
title="Move up"
disabled={index === 0}
className="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowUp className="h-4 w-4" />
</button>
</form>
<form action={moveCategoryAction}>
<input type="hidden" name="categoryId" value={category.id} />
<input type="hidden" name="direction" value="down" />
<button
type="submit"
title="Move down"
disabled={index === categories.length - 1}
className="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 disabled:opacity-30 disabled:hover:bg-transparent"
>
<ArrowDown className="h-4 w-4" />
</button>
</form>
<Link
href={`/admin/categories/${category.id}/edit`}
title="Edit"
className="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700"
>
<Pencil className="h-4 w-4" />
</Link>
<DeleteCategoryButton
categoryId={category.id}
label={category.label}
disabled={category._count.items > 0}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 text-xs text-zinc-400">
Friends only see categories marked as shared. Categories with items
can&apos;t be deleted remove or move their items first.
</p>
</div>
);
}

View File

@ -0,0 +1,101 @@
import { redirect } from "next/navigation";
import { CheckCircle2, CircleDashed } from "lucide-react";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import { env, s3Configured } from "@/lib/env";
import {
ChangePasswordForm,
ProfileForm,
} from "@/components/admin/SettingsForms";
function IntegrationRow({
label,
configured,
hint,
}: {
label: string;
configured: boolean;
hint: string;
}) {
return (
<li className="flex items-center gap-2.5 py-2">
{configured ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />
) : (
<CircleDashed className="h-4 w-4 shrink-0 text-zinc-300" />
)}
<span className="text-sm font-medium">{label}</span>
<span className="ml-auto text-xs text-zinc-400">
{configured ? "Configured" : hint}
</span>
</li>
);
}
export default async function SettingsPage() {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const user = await prisma.user.findUniqueOrThrow({
where: { id: viewer.userId },
select: { name: true, email: true },
});
return (
<div className="max-w-3xl space-y-10">
<div>
<h1 className="mb-6 text-2xl font-semibold">Settings</h1>
<h2 className="mb-4 text-lg font-semibold">Profile</h2>
<ProfileForm
initialName={user.name ?? ""}
initialEmail={user.email}
/>
</div>
<div>
<h2 className="mb-4 text-lg font-semibold">Password</h2>
<ChangePasswordForm />
</div>
<div>
<h2 className="mb-1 text-lg font-semibold">Integrations</h2>
<p className="mb-3 text-sm text-zinc-500">
Configured via environment variables in <code>.env</code> restart
the server after changing them.
</p>
<ul className="divide-y divide-zinc-100 rounded-xl border border-zinc-200 px-4">
<IntegrationRow
label="Image storage (S3)"
configured={s3Configured()}
hint="Set the S3_* variables to enable cover uploads"
/>
<IntegrationRow
label="Movies & TV (TMDB)"
configured={Boolean(env.TMDB_API_KEY)}
hint="Set TMDB_API_KEY"
/>
<IntegrationRow
label="Books (Google Books)"
configured={Boolean(env.GOOGLE_BOOKS_API_KEY)}
hint="Set GOOGLE_BOOKS_API_KEY"
/>
<IntegrationRow
label="Music (Spotify)"
configured={Boolean(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET)}
hint="Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET"
/>
<IntegrationRow
label="Restaurants (Google Places)"
configured={Boolean(env.GOOGLE_PLACES_API_KEY)}
hint="Set GOOGLE_PLACES_API_KEY"
/>
<IntegrationRow
label="Restaurants (Yelp)"
configured={Boolean(env.YELP_API_KEY)}
hint="Set YELP_API_KEY"
/>
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
import { redirect } from "next/navigation";
import { format } from "date-fns";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import {
AddFriendForm,
DeleteFriendButton,
ResetPasswordForm,
} from "@/components/admin/FriendForms";
export default async function UsersPage() {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const users = await prisma.user.findMany({
orderBy: [{ role: "asc" }, { createdAt: "asc" }],
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
},
});
return (
<div className="max-w-3xl">
<h1 className="mb-6 text-2xl font-semibold">Friends</h1>
<p className="mb-6 text-sm text-zinc-500">
Friends can sign in and browse the categories you&apos;ve marked as
shared. They can&apos;t add or change anything.
</p>
<div className="mb-10 overflow-hidden rounded-xl border border-zinc-200">
<table className="w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500">
<tr>
<th className="px-4 py-2.5 font-medium">Name</th>
<th className="px-4 py-2.5 font-medium">Email</th>
<th className="px-4 py-2.5 font-medium">Since</th>
<th className="px-4 py-2.5 font-medium">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{users.map((user) => (
<tr key={user.id} className="align-middle">
<td className="px-4 py-3 font-medium">
{user.name ?? "—"}
{user.role === "ADMIN" && (
<span className="ml-2 inline-flex rounded-full bg-zinc-900 px-2 py-0.5 text-xs font-medium text-white">
Admin
</span>
)}
</td>
<td className="px-4 py-3 text-zinc-600">{user.email}</td>
<td className="px-4 py-3 text-zinc-500">
{format(user.createdAt, "PP")}
</td>
<td className="px-4 py-3">
{user.role === "FRIEND" && (
<div className="flex items-center justify-end gap-1">
<ResetPasswordForm userId={user.id} />
<DeleteFriendButton
userId={user.id}
name={user.name ?? user.email}
/>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 className="mb-4 text-lg font-semibold">Add a friend</h2>
<AddFriendForm />
</div>
);
}

View File

@ -0,0 +1,102 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Plus, Search } from "lucide-react";
import type { Prisma } from "@prisma/client";
import { prisma } from "@/server/db/prisma";
import {
categoryWhereForViewer,
findVisibleItems,
getViewerContext,
} from "@/server/db/visibility";
import { ItemGrid } from "@/components/items/ItemGrid";
import { CategoryIcon } from "@/components/nav/CategoryIcon";
const SORTS: Record<string, Prisma.ItemOrderByWithRelationInput> = {
recent: { dateAdded: "desc" },
rating: { rating: { sort: "desc", nulls: "last" } },
title: { title: "asc" },
};
export default async function CategoryPage({
params,
searchParams,
}: PageProps<"/categories/[key]">) {
const { key } = await params;
const { q, sort } = await searchParams;
const viewer = await getViewerContext();
const category = await prisma.category.findFirst({
where: { AND: [categoryWhereForViewer(viewer), { key }] },
});
if (!category) notFound();
const query = typeof q === "string" ? q.trim() : "";
const sortKey = typeof sort === "string" && sort in SORTS ? sort : "recent";
const items = await findVisibleItems(viewer, {
where: {
categoryId: category.id,
...(query
? { title: { contains: query, mode: "insensitive" } }
: {}),
},
orderBy: SORTS[sortKey],
include: { category: true, itemTags: { include: { tag: true } } },
});
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2.5 text-2xl font-semibold">
<CategoryIcon name={category.icon} className="h-6 w-6 text-zinc-400" />
{category.label}
</h1>
{viewer.role === "ADMIN" && (
<Link
href={`/items/new?category=${category.key}`}
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-900 px-3 py-2 text-sm font-medium text-white hover:bg-zinc-700"
>
<Plus className="h-4 w-4" /> Add
</Link>
)}
</div>
<form className="mb-6 flex flex-wrap items-center gap-2" action="">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
<input
type="search"
name="q"
defaultValue={query}
placeholder={`Search ${category.label.toLowerCase()}`}
className="w-64 rounded-lg border border-zinc-300 py-2 pl-8 pr-3 text-sm outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200"
/>
</div>
<select
name="sort"
defaultValue={sortKey}
className="rounded-lg border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500"
>
<option value="recent">Recently added</option>
<option value="rating">Highest rated</option>
<option value="title">Title AZ</option>
</select>
<button
type="submit"
className="rounded-lg border border-zinc-300 px-3 py-2 text-sm hover:bg-zinc-50"
>
Apply
</button>
</form>
<ItemGrid
items={items}
emptyMessage={
query
? `No ${category.label.toLowerCase()} match “${query}”.`
: "Nothing here yet."
}
/>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import { parseFieldSchema, readCustomFields } from "@/lib/field-schema";
import {
ItemForm,
type CategoryOption,
type ItemFormInitial,
} from "@/components/items/ItemForm";
export default async function EditItemPage({
params,
}: PageProps<"/items/[id]/edit">) {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const { id } = await params;
const item = await prisma.item.findUnique({
where: { id },
include: { itemTags: { include: { tag: true } } },
});
if (!item) notFound();
const categories: CategoryOption[] = (
await prisma.category.findMany({ orderBy: { sortOrder: "asc" } })
).map((c) => ({
id: c.id,
key: c.key,
label: c.label,
fields: parseFieldSchema(c.fieldSchema),
}));
const initial: ItemFormInitial = {
id: item.id,
title: item.title,
categoryId: item.categoryId,
rating: item.rating,
description: item.description,
notesHtml: item.notesHtml,
tags: item.itemTags.map(({ tag }) => tag.name),
customFields: readCustomFields(item.customFields),
};
return (
<div>
<h1 className="mb-6 text-2xl font-semibold">Edit {item.title}</h1>
<ItemForm categories={categories} item={initial} />
</div>
);
}

View File

@ -0,0 +1,130 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { format } from "date-fns";
import { Pencil } from "lucide-react";
import { findVisibleItem, getViewerContext } from "@/server/db/visibility";
import { parseFieldSchema, readCustomFields } from "@/lib/field-schema";
import { coverImageUrl } from "@/lib/images";
import { CategoryIcon } from "@/components/nav/CategoryIcon";
import { RatingStars } from "@/components/items/RatingStars";
import { TagBadge } from "@/components/items/TagBadge";
import { DeleteItemButton } from "@/components/items/DeleteItemButton";
function isUrl(value: string | number): value is string {
return typeof value === "string" && /^https?:\/\//.test(value);
}
export default async function ItemPage({ params }: PageProps<"/items/[id]">) {
const { id } = await params;
const viewer = await getViewerContext();
const item = await findVisibleItem(viewer, id);
if (!item) notFound();
const cover = coverImageUrl(item.coverImageKey);
const customFields = readCustomFields(item.customFields);
const schema = parseFieldSchema(item.category.fieldSchema);
// Schema order first, then any leftover stored keys (e.g. after a schema edit).
const fields = [
...schema
.filter((f) => customFields[f.key] !== undefined)
.map((f) => ({ label: f.label, value: customFields[f.key] })),
...Object.entries(customFields)
.filter(([key]) => !schema.some((f) => f.key === key))
.map(([key, value]) => ({ label: key, value })),
];
return (
<article className="max-w-3xl">
<div className="mb-1 flex items-center gap-2 text-sm text-zinc-500">
<CategoryIcon name={item.category.icon} className="h-4 w-4" />
<Link href={`/categories/${item.category.key}`} className="hover:underline">
{item.category.label}
</Link>
</div>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">{item.title}</h1>
<div className="mt-1.5 flex flex-wrap items-center gap-3">
<RatingStars rating={item.rating} />
<span className="text-xs text-zinc-400">
Added {format(item.dateAdded, "PP")}
</span>
</div>
</div>
{viewer.role === "ADMIN" && (
<div className="flex items-center gap-2">
<Link
href={`/items/${item.id}/edit`}
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-300 px-3 py-2 text-sm hover:bg-zinc-50"
>
<Pencil className="h-4 w-4" /> Edit
</Link>
<DeleteItemButton itemId={item.id} />
</div>
)}
</div>
{cover && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover}
alt=""
className="mb-6 max-h-80 rounded-xl object-cover"
/>
)}
{item.description && (
<p className="mb-6 text-zinc-600">{item.description}</p>
)}
{fields.length > 0 && (
<dl className="mb-6 grid grid-cols-1 gap-x-8 gap-y-3 rounded-xl border border-zinc-200 p-4 sm:grid-cols-2">
{fields.map(({ label, value }) => (
<div key={label}>
<dt className="text-xs uppercase tracking-wide text-zinc-400">
{label}
</dt>
<dd className="mt-0.5 break-words text-sm text-zinc-800">
{isUrl(value) ? (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline"
>
{value}
</a>
) : (
String(value)
)}
</dd>
</div>
))}
</dl>
)}
{item.itemTags.length > 0 && (
<div className="mb-6 flex flex-wrap gap-1.5">
{item.itemTags.map(({ tag }) => (
<TagBadge key={tag.id} name={tag.name} />
))}
</div>
)}
{item.notesHtml && (
<section>
<h2 className="mb-2 text-sm font-medium uppercase tracking-wide text-zinc-400">
Notes
</h2>
<div
className="prose-notes text-sm text-zinc-800"
// Sanitized with sanitize-html before storage (see item.actions.ts).
dangerouslySetInnerHTML={{ __html: item.notesHtml }}
/>
</section>
)}
</article>
);
}

View File

@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import { parseFieldSchema } from "@/lib/field-schema";
import { ItemForm, type CategoryOption } from "@/components/items/ItemForm";
export default async function NewItemPage({
searchParams,
}: PageProps<"/items/new">) {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const { category: categoryKey } = await searchParams;
const categories: CategoryOption[] = (
await prisma.category.findMany({ orderBy: { sortOrder: "asc" } })
).map((c) => ({
id: c.id,
key: c.key,
label: c.label,
fields: parseFieldSchema(c.fieldSchema),
}));
const preselected =
typeof categoryKey === "string"
? categories.find((c) => c.key === categoryKey)
: undefined;
return (
<div>
<h1 className="mb-6 text-2xl font-semibold">Add item</h1>
<ItemForm categories={categories} initialCategoryId={preselected?.id} />
</div>
);
}

41
src/app/(app)/layout.tsx Normal file
View File

@ -0,0 +1,41 @@
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/nav/Sidebar";
import {
findVisibleCategories,
getViewerContext,
UnauthenticatedError,
} from "@/server/db/visibility";
import { prisma } from "@/server/db/prisma";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
let viewer;
try {
viewer = await getViewerContext();
} catch (e) {
if (e instanceof UnauthenticatedError) redirect("/login");
throw e;
}
const [categories, user] = await Promise.all([
findVisibleCategories(viewer),
prisma.user.findUnique({
where: { id: viewer.userId },
select: { name: true, email: true },
}),
]);
return (
<div className="flex min-h-screen">
<Sidebar
categories={categories}
role={viewer.role}
userName={user?.name || user?.email || ""}
/>
<main className="flex-1 overflow-x-hidden p-6 lg:p-8">{children}</main>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { notFound, redirect } from "next/navigation";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import { LibraryItemForm } from "@/components/library/LibraryItemForm";
export default async function EditLibraryItemPage({
params,
}: PageProps<"/library/[id]/edit">) {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const { id } = await params;
const entry = await prisma.libraryItem.findUnique({ where: { id } });
if (!entry) notFound();
const linkableItems = await prisma.item.findMany({
select: { id: true, title: true },
orderBy: { title: "asc" },
});
return (
<div>
<h1 className="mb-6 text-2xl font-semibold">Edit library entry</h1>
<LibraryItemForm
linkableItems={linkableItems}
entry={{
id: entry.id,
itemId: entry.itemId,
standaloneTitle: entry.standaloneTitle,
mediaType: entry.mediaType,
condition: entry.condition,
notes: entry.notes,
}}
/>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import { LibraryItemForm } from "@/components/library/LibraryItemForm";
export default async function NewLibraryItemPage() {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const linkableItems = await prisma.item.findMany({
select: { id: true, title: true },
orderBy: { title: "asc" },
});
return (
<div>
<h1 className="mb-6 text-2xl font-semibold">Add to library</h1>
<LibraryItemForm linkableItems={linkableItems} />
</div>
);
}

View File

@ -0,0 +1,170 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { format } from "date-fns";
import { HandHeart, Pencil, Plus, Undo2 } from "lucide-react";
import { prisma } from "@/server/db/prisma";
import { getViewerContext } from "@/server/db/visibility";
import {
lendLibraryItemAction,
returnLibraryItemAction,
} from "@/server/actions/library.actions";
import { DeleteLibraryItemButton } from "@/components/library/DeleteLibraryItemButton";
const inputClass =
"rounded-lg border border-zinc-300 px-2.5 py-1.5 text-sm outline-none focus:border-zinc-500";
export default async function LibraryPage() {
const viewer = await getViewerContext();
if (viewer.role !== "ADMIN") redirect("/");
const entries = await prisma.libraryItem.findMany({
where: { ownerId: viewer.userId },
include: { item: { select: { id: true, title: true } } },
orderBy: [{ loanStatus: "desc" }, { updatedAt: "desc" }],
});
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">My Library</h1>
<Link
href="/library/new"
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-900 px-3 py-2 text-sm font-medium text-white hover:bg-zinc-700"
>
<Plus className="h-4 w-4" /> Add
</Link>
</div>
{entries.length === 0 ? (
<p className="text-sm text-zinc-500">
Track your physical books, discs, and records here and who borrowed
them.
</p>
) : (
<div className="overflow-x-auto rounded-xl border border-zinc-200">
<table className="w-full min-w-160 text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wide text-zinc-500">
<tr>
<th className="px-4 py-2.5 font-medium">Title</th>
<th className="px-4 py-2.5 font-medium">Type</th>
<th className="px-4 py-2.5 font-medium">Status</th>
<th className="px-4 py-2.5 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{entries.map((entry) => {
const title =
entry.item?.title ?? entry.standaloneTitle ?? "(untitled)";
const lent = entry.loanStatus === "LENT_OUT";
return (
<tr key={entry.id} className="align-top">
<td className="px-4 py-3">
{entry.item ? (
<Link
href={`/items/${entry.item.id}`}
className="font-medium hover:underline"
>
{title}
</Link>
) : (
<span className="font-medium">{title}</span>
)}
{entry.condition && (
<p className="text-xs text-zinc-400">{entry.condition}</p>
)}
</td>
<td className="px-4 py-3 text-zinc-600">
{entry.mediaType ?? "—"}
</td>
<td className="px-4 py-3">
{lent ? (
<div>
<span className="inline-flex rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
Lent to {entry.borrowerName}
</span>
<p className="mt-1 text-xs text-zinc-400">
{entry.dateLent && `since ${format(entry.dateLent, "PP")}`}
{entry.expectedReturnDate &&
` · due ${format(entry.expectedReturnDate, "PP")}`}
</p>
</div>
) : (
<span className="inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-800">
Available
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-start gap-1">
{lent ? (
<form action={returnLibraryItemAction}>
<input
type="hidden"
name="libraryItemId"
value={entry.id}
/>
<button
type="submit"
className="inline-flex items-center gap-1 rounded-lg border border-zinc-300 px-2.5 py-1.5 text-xs hover:bg-zinc-50"
>
<Undo2 className="h-3.5 w-3.5" /> Mark returned
</button>
</form>
) : (
<details className="relative">
<summary className="inline-flex cursor-pointer list-none items-center gap-1 rounded-lg border border-zinc-300 px-2.5 py-1.5 text-xs hover:bg-zinc-50">
<HandHeart className="h-3.5 w-3.5" /> Lend
</summary>
<form
action={lendLibraryItemAction}
className="absolute right-0 z-10 mt-1 w-64 space-y-2 rounded-xl border border-zinc-200 bg-white p-3 shadow-lg"
>
<input
type="hidden"
name="libraryItemId"
value={entry.id}
/>
<input
type="text"
name="borrowerName"
required
placeholder="Borrower's name"
className={`w-full ${inputClass}`}
/>
<label className="block text-xs text-zinc-500">
Expected return (optional)
<input
type="date"
name="expectedReturnDate"
className={`mt-1 w-full ${inputClass}`}
/>
</label>
<button
type="submit"
className="w-full rounded-lg bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-zinc-700"
>
Lend it
</button>
</form>
</details>
)}
<Link
href={`/library/${entry.id}/edit`}
title="Edit"
className="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700"
>
<Pencil className="h-4 w-4" />
</Link>
<DeleteLibraryItemButton libraryItemId={entry.id} />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

22
src/app/(app)/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import { findVisibleItems, getViewerContext } from "@/server/db/visibility";
import { ItemGrid } from "@/components/items/ItemGrid";
export default async function DashboardPage() {
const viewer = await getViewerContext();
const recent = await findVisibleItems(viewer, {
orderBy: { dateAdded: "desc" },
take: 24,
include: { category: true, itemTags: { include: { tag: true } } },
});
return (
<div>
<h1 className="mb-6 text-2xl font-semibold">Recently added</h1>
<ItemGrid
items={recent}
showCategory
emptyMessage="Nothing here yet. Add your first favorite from a category page."
/>
</div>
);
}

View File

@ -0,0 +1,54 @@
"use client";
import { useActionState } from "react";
import { loginAction, type LoginState } from "@/server/actions/auth.actions";
export function LoginForm() {
const [state, formAction, pending] = useActionState<LoginState, FormData>(
loginAction,
{}
);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200"
/>
</div>
<div>
<label htmlFor="password" className="mb-1 block text-sm font-medium">
Password
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200"
/>
</div>
{state.error && (
<p className="text-sm text-red-600" role="alert">
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="w-full rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50"
>
{pending ? "Signing in…" : "Sign in"}
</button>
</form>
);
}

View File

@ -0,0 +1,15 @@
import { LoginForm } from "./login-form";
export const metadata = { title: "Sign in — My Favorite Stuff" };
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-white p-8 shadow-sm">
<h1 className="mb-1 text-2xl font-semibold">My Favorite Stuff</h1>
<p className="mb-6 text-sm text-zinc-500">Sign in to your account</p>
<LoginForm />
</div>
</main>
);
}

View File

@ -0,0 +1,3 @@
import { handlers } from "@/server/auth/auth";
export const { GET, POST } = handlers;

View File

@ -24,3 +24,70 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Typography for Tiptap notes — used by both the editor and rendered item notes. */
.prose-notes {
line-height: 1.6;
}
.prose-notes p {
margin: 0.5rem 0;
}
.prose-notes h1,
.prose-notes h2 {
margin: 1rem 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
}
.prose-notes h3,
.prose-notes h4 {
margin: 0.875rem 0 0.375rem;
font-size: 1rem;
font-weight: 600;
}
.prose-notes ul,
.prose-notes ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.prose-notes ul {
list-style: disc;
}
.prose-notes ol {
list-style: decimal;
}
.prose-notes blockquote {
margin: 0.75rem 0;
border-left: 3px solid #d4d4d8;
padding-left: 0.75rem;
color: #52525b;
}
.prose-notes code {
border-radius: 0.25rem;
background: #f4f4f5;
padding: 0.125rem 0.25rem;
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
}
.prose-notes pre {
margin: 0.75rem 0;
overflow-x: auto;
border-radius: 0.5rem;
background: #f4f4f5;
padding: 0.75rem;
}
.prose-notes pre code {
background: none;
padding: 0;
}
.prose-notes a {
color: #2563eb;
text-decoration: underline;
}
.prose-notes img {
max-width: 100%;
border-radius: 0.5rem;
}
.prose-notes hr {
margin: 1rem 0;
border-color: #e4e4e7;
}

View File

@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Geist } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
@ -7,14 +7,9 @@ const geistSans = Geist({
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "My Favorite Stuff",
description: "Personal library of favorite things",
};
export default function RootLayout({
@ -23,11 +18,10 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="en" className={`${geistSans.variable} h-full antialiased`}>
<body className="min-h-full bg-zinc-50 font-sans text-zinc-900">
{children}
</body>
</html>
);
}

View File

@ -1,65 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,358 @@
"use client";
import { useActionState, useState } from "react";
import { ArrowDown, ArrowUp, Plus, X } from "lucide-react";
import {
createCategoryAction,
updateCategoryAction,
type CategoryFormState,
} from "@/server/actions/category.actions";
import type { FieldDef } from "@/lib/field-schema";
import { CategoryIcon } from "@/components/nav/CategoryIcon";
export type CategoryFormInitial = {
id: string;
label: string;
key: string;
icon: string | null;
isShared: boolean;
fields: FieldDef[];
};
const ICON_SUGGESTIONS = [
"BookOpen",
"Clapperboard",
"Tv",
"ChefHat",
"Youtube",
"Music",
"Mic2",
"UtensilsCrossed",
"Newspaper",
"Gamepad2",
"Palette",
"Plane",
"Camera",
"Dumbbell",
"Wine",
"Podcast",
"Sparkles",
];
const FIELD_TYPES: Array<{ value: FieldDef["type"]; label: string }> = [
{ value: "text", label: "Text" },
{ value: "textarea", label: "Long text" },
{ value: "number", label: "Number" },
{ value: "date", label: "Date" },
{ value: "url", label: "URL" },
{ value: "select", label: "Choice list" },
];
const inputClass =
"w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200";
const labelClass = "mb-1 block text-sm font-medium";
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** camelCase key from a field label, e.g. "Published year" -> "publishedYear". */
function fieldKeyFromLabel(label: string): string {
const words = label
.toLowerCase()
.replace(/[^a-z0-9 ]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean);
return words
.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1)))
.join("");
}
type EditableField = FieldDef & { optionsText?: string; keyTouched?: boolean };
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return (
<p className="mt-1 text-sm text-red-600" role="alert">
{message}
</p>
);
}
export function CategoryForm({ category }: { category?: CategoryFormInitial }) {
const [state, formAction, pending] = useActionState<CategoryFormState, FormData>(
category ? updateCategoryAction : createCategoryAction,
{}
);
const errors = state.fieldErrors ?? {};
const [label, setLabel] = useState(category?.label ?? "");
const [key, setKey] = useState(category?.key ?? "");
const [keyTouched, setKeyTouched] = useState(Boolean(category));
const [icon, setIcon] = useState(category?.icon ?? "");
const [fields, setFields] = useState<EditableField[]>(
(category?.fields ?? []).map((f) => ({
...f,
optionsText: (f.options ?? []).join(", "),
keyTouched: true,
}))
);
const updateField = (index: number, patch: Partial<EditableField>) =>
setFields((prev) => prev.map((f, i) => (i === index ? { ...f, ...patch } : f)));
const moveField = (index: number, delta: number) =>
setFields((prev) => {
const target = index + delta;
if (target < 0 || target >= prev.length) return prev;
const next = [...prev];
[next[index], next[target]] = [next[target], next[index]];
return next;
});
const serialized = JSON.stringify(
fields
.filter((f) => f.label.trim() && f.key.trim())
.map((f) => ({
key: f.key.trim(),
label: f.label.trim(),
type: f.type,
...(f.required ? { required: true } : {}),
...(f.type === "select"
? {
options: (f.optionsText ?? "")
.split(",")
.map((o) => o.trim())
.filter(Boolean),
}
: {}),
}))
);
return (
<form action={formAction} className="max-w-2xl space-y-5">
{category && <input type="hidden" name="categoryId" value={category.id} />}
<input type="hidden" name="fieldSchema" value={serialized} />
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="cat-label" className={labelClass}>
Name
</label>
<input
id="cat-label"
name="label"
type="text"
required
value={label}
onChange={(e) => {
setLabel(e.target.value);
if (!keyTouched) setKey(slugify(e.target.value));
}}
className={inputClass}
/>
<FieldError message={errors.label} />
</div>
<div>
<label htmlFor="cat-key" className={labelClass}>
URL key
</label>
<input
id="cat-key"
name="key"
type="text"
required
value={key}
onChange={(e) => {
setKey(e.target.value);
setKeyTouched(true);
}}
pattern="[a-z][a-z0-9-]*"
className={`${inputClass} font-mono`}
/>
<p className="mt-1 text-xs text-zinc-400">/categories/{key || "…"}</p>
<FieldError message={errors.key} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="cat-icon" className={labelClass}>
Icon
</label>
<div className="flex items-center gap-2">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50">
<CategoryIcon name={icon} className="h-4 w-4 text-zinc-600" />
</span>
<input
id="cat-icon"
name="icon"
type="text"
list="icon-suggestions"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="Lucide icon name"
className={inputClass}
/>
<datalist id="icon-suggestions">
{ICON_SUGGESTIONS.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
<p className="mt-1 text-xs text-zinc-400">
Any icon name from lucide.dev unknown names fall back to
</p>
</div>
<div className="flex items-start pt-7">
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
name="isShared"
defaultChecked={category?.isShared ?? false}
className="h-4 w-4 rounded border-zinc-300"
/>
Shared with friends
</label>
</div>
</div>
<fieldset className="space-y-3 rounded-xl border border-zinc-200 p-4">
<legend className="px-1 text-sm font-medium text-zinc-500">
Custom fields
</legend>
{fields.length === 0 && (
<p className="text-sm text-zinc-400">
No custom fields yet items will just have the standard title,
rating, tags, and notes.
</p>
)}
{fields.map((field, index) => (
<div
key={index}
className="space-y-2 rounded-lg border border-zinc-100 bg-zinc-50/60 p-3"
>
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={field.label}
onChange={(e) =>
updateField(index, {
label: e.target.value,
...(field.keyTouched
? {}
: { key: fieldKeyFromLabel(e.target.value) }),
})
}
placeholder="Field name"
className={`${inputClass} !w-40 flex-1`}
/>
<input
type="text"
value={field.key}
onChange={(e) =>
updateField(index, { key: e.target.value, keyTouched: true })
}
placeholder="key"
className={`${inputClass} !w-32 font-mono text-xs`}
/>
<select
value={field.type}
onChange={(e) =>
updateField(index, { type: e.target.value as FieldDef["type"] })
}
className={`${inputClass} !w-32`}
>
{FIELD_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<label className="flex items-center gap-1.5 text-xs text-zinc-600">
<input
type="checkbox"
checked={field.required ?? false}
onChange={(e) => updateField(index, { required: e.target.checked })}
className="h-3.5 w-3.5 rounded border-zinc-300"
/>
Required
</label>
<span className="ml-auto flex items-center">
<button
type="button"
title="Move up"
onClick={() => moveField(index, -1)}
disabled={index === 0}
className="rounded p-1 text-zinc-400 hover:bg-zinc-200 disabled:opacity-30"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="Move down"
onClick={() => moveField(index, 1)}
disabled={index === fields.length - 1}
className="rounded p-1 text-zinc-400 hover:bg-zinc-200 disabled:opacity-30"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="Remove field"
onClick={() => setFields((prev) => prev.filter((_, i) => i !== index))}
className="rounded p-1 text-zinc-400 hover:bg-red-100 hover:text-red-600"
>
<X className="h-3.5 w-3.5" />
</button>
</span>
</div>
{field.type === "select" && (
<input
type="text"
value={field.optionsText ?? ""}
onChange={(e) => updateField(index, { optionsText: e.target.value })}
placeholder="Options: comma, separated, choices"
className={inputClass}
/>
)}
</div>
))}
<button
type="button"
onClick={() =>
setFields((prev) => [...prev, { key: "", label: "", type: "text" }])
}
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-zinc-300 px-3 py-1.5 text-sm text-zinc-500 hover:border-zinc-400 hover:text-zinc-700"
>
<Plus className="h-4 w-4" /> Add field
</button>
</fieldset>
{category && (
<p className="text-xs text-zinc-400">
Renaming or removing a field keeps existing values on items until each
item is next saved.
</p>
)}
{state.error && (
<p className="text-sm text-red-600" role="alert">
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50"
>
{pending ? "Saving…" : category ? "Save changes" : "Create category"}
</button>
</form>
);
}

View File

@ -0,0 +1,43 @@
"use client";
import { Trash2 } from "lucide-react";
import { deleteCategoryAction } from "@/server/actions/category.actions";
export function DeleteCategoryButton({
categoryId,
label,
disabled,
}: {
categoryId: string;
label: string;
disabled: boolean;
}) {
if (disabled) {
return (
<span
title="Remove or move its items first"
className="rounded-md p-1.5 text-zinc-200"
>
<Trash2 className="h-4 w-4" />
</span>
);
}
return (
<form
action={deleteCategoryAction}
onSubmit={(e) => {
if (!confirm(`Delete the “${label}” category?`)) e.preventDefault();
}}
>
<input type="hidden" name="categoryId" value={categoryId} />
<button
type="submit"
title="Delete category"
className="rounded-md p-1.5 text-zinc-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</form>
);
}

View File

@ -0,0 +1,165 @@
"use client";
import { useActionState } from "react";
import { KeyRound, Trash2, UserPlus } from "lucide-react";
import {
createFriendAction,
deleteFriendAction,
resetFriendPasswordAction,
type UserFormState,
} from "@/server/actions/user.actions";
const inputClass =
"w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200";
const labelClass = "mb-1 block text-sm font-medium";
function Feedback({ state }: { state: UserFormState }) {
if (state.error)
return (
<p className="text-sm text-red-600" role="alert">
{state.error}
</p>
);
if (state.success)
return <p className="text-sm text-emerald-700">{state.success}</p>;
return null;
}
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return (
<p className="mt-1 text-sm text-red-600" role="alert">
{message}
</p>
);
}
export function AddFriendForm() {
const [state, formAction, pending] = useActionState<UserFormState, FormData>(
createFriendAction,
{}
);
const errors = state.fieldErrors ?? {};
return (
<form action={formAction} className="max-w-md space-y-4">
<div>
<label htmlFor="friend-name" className={labelClass}>
Name
</label>
<input
id="friend-name"
name="name"
type="text"
required
className={inputClass}
/>
<FieldError message={errors.name} />
</div>
<div>
<label htmlFor="friend-email" className={labelClass}>
Email
</label>
<input
id="friend-email"
name="email"
type="email"
required
className={inputClass}
/>
<FieldError message={errors.email} />
</div>
<div>
<label htmlFor="friend-password" className={labelClass}>
Initial password
</label>
<input
id="friend-password"
name="password"
type="text"
required
minLength={8}
autoComplete="off"
placeholder="Share this with your friend"
className={inputClass}
/>
<FieldError message={errors.password} />
</div>
<Feedback state={state} />
<button
type="submit"
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50"
>
<UserPlus className="h-4 w-4" />
{pending ? "Adding…" : "Add friend"}
</button>
</form>
);
}
export function ResetPasswordForm({ userId }: { userId: string }) {
const [state, formAction, pending] = useActionState<UserFormState, FormData>(
resetFriendPasswordAction,
{}
);
return (
<details className="relative">
<summary className="inline-flex cursor-pointer list-none items-center gap-1 rounded-lg border border-zinc-300 px-2.5 py-1.5 text-xs hover:bg-zinc-50">
<KeyRound className="h-3.5 w-3.5" /> Reset password
</summary>
<form
action={formAction}
className="absolute right-0 z-10 mt-1 w-64 space-y-2 rounded-xl border border-zinc-200 bg-white p-3 shadow-lg"
>
<input type="hidden" name="userId" value={userId} />
<input
type="text"
name="password"
required
minLength={8}
autoComplete="off"
placeholder="New password"
className={inputClass}
/>
<Feedback state={state} />
<button
type="submit"
disabled={pending}
className="w-full rounded-lg bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white hover:bg-zinc-700 disabled:opacity-50"
>
{pending ? "Saving…" : "Set new password"}
</button>
</form>
</details>
);
}
export function DeleteFriendButton({
userId,
name,
}: {
userId: string;
name: string;
}) {
return (
<form
action={deleteFriendAction}
onSubmit={(e) => {
if (!confirm(`Remove ${name}? They will no longer be able to sign in.`)) {
e.preventDefault();
}
}}
>
<input type="hidden" name="userId" value={userId} />
<button
type="submit"
title="Remove friend"
className="rounded-md p-1.5 text-zinc-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</form>
);
}

View File

@ -0,0 +1,138 @@
"use client";
import { useActionState } from "react";
import {
changePasswordAction,
updateProfileAction,
type UserFormState,
} from "@/server/actions/user.actions";
const inputClass =
"w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200";
const labelClass = "mb-1 block text-sm font-medium";
function Feedback({ state }: { state: UserFormState }) {
if (state.error)
return (
<p className="text-sm text-red-600" role="alert">
{state.error}
</p>
);
if (state.success)
return <p className="text-sm text-emerald-700">{state.success}</p>;
return null;
}
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return (
<p className="mt-1 text-sm text-red-600" role="alert">
{message}
</p>
);
}
export function ProfileForm({
initialName,
initialEmail,
}: {
initialName: string;
initialEmail: string;
}) {
const [state, formAction, pending] = useActionState<UserFormState, FormData>(
updateProfileAction,
{}
);
const errors = state.fieldErrors ?? {};
return (
<form action={formAction} className="max-w-md space-y-4">
<div>
<label htmlFor="profile-name" className={labelClass}>
Name
</label>
<input
id="profile-name"
name="name"
type="text"
required
defaultValue={initialName}
className={inputClass}
/>
<FieldError message={errors.name} />
</div>
<div>
<label htmlFor="profile-email" className={labelClass}>
Email
</label>
<input
id="profile-email"
name="email"
type="email"
required
defaultValue={initialEmail}
className={inputClass}
/>
<FieldError message={errors.email} />
</div>
<Feedback state={state} />
<button
type="submit"
disabled={pending}
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50"
>
{pending ? "Saving…" : "Save profile"}
</button>
</form>
);
}
export function ChangePasswordForm() {
const [state, formAction, pending] = useActionState<UserFormState, FormData>(
changePasswordAction,
{}
);
const errors = state.fieldErrors ?? {};
return (
<form action={formAction} className="max-w-md space-y-4">
<div>
<label htmlFor="current-password" className={labelClass}>
Current password
</label>
<input
id="current-password"
name="currentPassword"
type="password"
required
autoComplete="current-password"
className={inputClass}
/>
<FieldError message={errors.currentPassword} />
</div>
<div>
<label htmlFor="new-password" className={labelClass}>
New password
</label>
<input
id="new-password"
name="newPassword"
type="password"
required
minLength={8}
autoComplete="new-password"
className={inputClass}
/>
<FieldError message={errors.newPassword} />
</div>
<Feedback state={state} />
<button
type="submit"
disabled={pending}
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50"
>
{pending ? "Saving…" : "Change password"}
</button>
</form>
);
}

View File

@ -0,0 +1,25 @@
"use client";
import { Trash2 } from "lucide-react";
import { deleteItemAction } from "@/server/actions/item.actions";
export function DeleteItemButton({ itemId }: { itemId: string }) {
return (
<form
action={deleteItemAction}
onSubmit={(e) => {
if (!confirm("Delete this item? This cannot be undone.")) {
e.preventDefault();
}
}}
>
<input type="hidden" name="itemId" value={itemId} />
<button
type="submit"
className="inline-flex items-center gap-1.5 rounded-lg border border-red-200 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" /> Delete
</button>
</form>
);
}

View File

@ -0,0 +1,67 @@
import Link from "next/link";
import type { Prisma } from "@prisma/client";
import { CategoryIcon } from "@/components/nav/CategoryIcon";
import { RatingStars } from "@/components/items/RatingStars";
import { TagBadge } from "@/components/items/TagBadge";
import { coverImageUrl } from "@/lib/images";
export type ItemForCard = Prisma.ItemGetPayload<{
include: { category: true; itemTags: { include: { tag: true } } };
}>;
type Props = {
item: ItemForCard;
/** Show the category name (useful on the dashboard's mixed grid). */
showCategory?: boolean;
};
export function ItemCard({ item, showCategory = false }: Props) {
const cover = coverImageUrl(item.coverImageKey);
return (
<Link
href={`/items/${item.id}`}
className="group flex flex-col overflow-hidden rounded-xl border border-zinc-200 bg-white transition hover:border-zinc-300 hover:shadow-sm"
>
<div className="flex h-36 items-center justify-center bg-zinc-100">
{cover ? (
// Plain <img>: covers live on external S3-compatible storage.
// eslint-disable-next-line @next/next/no-img-element
<img
src={cover}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<CategoryIcon
name={item.category.icon}
className="h-10 w-10 text-zinc-300"
/>
)}
</div>
<div className="flex flex-1 flex-col gap-1.5 p-3">
{showCategory && (
<p className="text-xs uppercase tracking-wide text-zinc-400">
{item.category.label}
</p>
)}
<h3 className="text-sm font-medium leading-snug text-zinc-900 group-hover:underline">
{item.title}
</h3>
<RatingStars rating={item.rating} />
{item.description && (
<p className="line-clamp-2 text-xs text-zinc-500">{item.description}</p>
)}
{item.itemTags.length > 0 && (
<div className="mt-auto flex flex-wrap gap-1 pt-1.5">
{item.itemTags.map(({ tag }) => (
<TagBadge key={tag.id} name={tag.name} />
))}
</div>
)}
</div>
</Link>
);
}

View File

@ -0,0 +1,229 @@
"use client";
import { useActionState, useState } from "react";
import {
createItemAction,
updateItemAction,
type ItemFormState,
} from "@/server/actions/item.actions";
import type { FieldDef } from "@/lib/field-schema";
import { NotesEditor } from "@/components/items/NotesEditor";
import { RatingInput } from "@/components/items/RatingInput";
export type CategoryOption = {
id: string;
key: string;
label: string;
fields: FieldDef[];
};
export type ItemFormInitial = {
id: string;
title: string;
categoryId: string;
rating: number | null;
description: string | null;
notesHtml: string | null;
tags: string[];
customFields: Record<string, string | number>;
};
type Props = {
categories: CategoryOption[];
/** Present when editing; absent when creating. */
item?: ItemFormInitial;
/** Preselected category for new items (e.g. from ?category=books). */
initialCategoryId?: string;
};
const inputClass =
"w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200";
const labelClass = "mb-1 block text-sm font-medium";
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return (
<p className="mt-1 text-sm text-red-600" role="alert">
{message}
</p>
);
}
function CustomFieldInput({
field,
defaultValue,
error,
}: {
field: FieldDef;
defaultValue?: string | number;
error?: string;
}) {
const name = `cf.${field.key}`;
const common = {
id: name,
name,
defaultValue: defaultValue ?? "",
required: field.required,
className: inputClass,
};
return (
<div>
<label htmlFor={name} className={labelClass}>
{field.label}
</label>
{field.type === "textarea" ? (
<textarea {...common} rows={3} />
) : field.type === "select" ? (
<select {...common}>
<option value=""></option>
{(field.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
{...common}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "url"
? "url"
: "text"
}
step={field.type === "number" ? "any" : undefined}
/>
)}
<FieldError message={error} />
</div>
);
}
export function ItemForm({ categories, item, initialCategoryId }: Props) {
const [state, formAction, pending] = useActionState<ItemFormState, FormData>(
item ? updateItemAction : createItemAction,
{}
);
const [categoryId, setCategoryId] = useState(
item?.categoryId ?? initialCategoryId ?? categories[0]?.id ?? ""
);
const category = categories.find((c) => c.id === categoryId);
const errors = state.fieldErrors ?? {};
return (
<form action={formAction} className="max-w-2xl space-y-5">
{item && <input type="hidden" name="itemId" value={item.id} />}
<div>
<label htmlFor="title" className={labelClass}>
Title
</label>
<input
id="title"
name="title"
type="text"
required
defaultValue={item?.title ?? ""}
className={inputClass}
/>
<FieldError message={errors.title} />
</div>
<div>
<label htmlFor="categoryId" className={labelClass}>
Category
</label>
<select
id="categoryId"
name="categoryId"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className={inputClass}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
<FieldError message={errors.categoryId} />
</div>
<div>
<span className={labelClass}>Rating</span>
<RatingInput name="rating" initialRating={item?.rating} />
<FieldError message={errors.rating} />
</div>
<div>
<label htmlFor="description" className={labelClass}>
Short description
</label>
<textarea
id="description"
name="description"
rows={2}
maxLength={500}
defaultValue={item?.description ?? ""}
placeholder="One or two lines shown on the card"
className={inputClass}
/>
<FieldError message={errors.description} />
</div>
{category && category.fields.length > 0 && (
<fieldset className="space-y-4 rounded-xl border border-zinc-200 p-4">
<legend className="px-1 text-sm font-medium text-zinc-500">
{category.label} details
</legend>
{category.fields.map((field) => (
<CustomFieldInput
key={field.key}
field={field}
defaultValue={item?.customFields[field.key]}
error={errors[field.key]}
/>
))}
</fieldset>
)}
<div>
<label htmlFor="tags" className={labelClass}>
Tags
</label>
<input
id="tags"
name="tags"
type="text"
defaultValue={item?.tags.join(", ") ?? ""}
placeholder="comma, separated, tags"
className={inputClass}
/>
<FieldError message={errors.tags} />
</div>
<div>
<span className={labelClass}>Notes</span>
<NotesEditor name="notesHtml" initialHtml={item?.notesHtml} />
</div>
{state.error && (
<p className="text-sm text-red-600" role="alert">
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50"
>
{pending ? "Saving…" : item ? "Save changes" : "Add item"}
</button>
</form>
);
}

View File

@ -0,0 +1,25 @@
import { ItemCard, type ItemForCard } from "@/components/items/ItemCard";
type Props = {
items: ItemForCard[];
showCategory?: boolean;
emptyMessage?: string;
};
export function ItemGrid({
items,
showCategory = false,
emptyMessage = "Nothing here yet.",
}: Props) {
if (items.length === 0) {
return <p className="text-sm text-zinc-500">{emptyMessage}</p>;
}
return (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{items.map((item) => (
<ItemCard key={item.id} item={item} showCategory={showCategory} />
))}
</div>
);
}

View File

@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import { EditorContent, useEditor, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import {
Bold,
Italic,
Strikethrough,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Link2,
Link2Off,
ImagePlus,
Undo2,
Redo2,
} from "lucide-react";
type Props = {
/** Name of the hidden input the sanitized-on-server HTML is submitted under. */
name: string;
initialHtml?: string | null;
};
function ToolbarButton({
onClick,
active,
title,
children,
}: {
onClick: () => void;
active?: boolean;
title: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => e.preventDefault()} // keep editor focus
onClick={onClick}
className={`rounded p-1.5 text-zinc-600 hover:bg-zinc-100 ${
active ? "bg-zinc-200 text-zinc-900" : ""
}`}
>
{children}
</button>
);
}
function Toolbar({ editor }: { editor: Editor }) {
const setLink = () => {
const previous = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("Link URL", previous ?? "https://");
if (url === null) return;
if (url === "") {
editor.chain().focus().unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};
const addImage = () => {
const url = window.prompt("Image URL", "https://");
if (!url) return;
editor.chain().focus().setImage({ src: url }).run();
};
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-zinc-200 px-2 py-1">
<ToolbarButton
title="Bold"
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
title="Italic"
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
title="Strikethrough"
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
>
<Strikethrough className="h-4 w-4" />
</ToolbarButton>
<span className="mx-1 h-4 w-px bg-zinc-200" />
<ToolbarButton
title="Heading"
active={editor.isActive("heading", { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
title="Subheading"
active={editor.isActive("heading", { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3 className="h-4 w-4" />
</ToolbarButton>
<span className="mx-1 h-4 w-px bg-zinc-200" />
<ToolbarButton
title="Bullet list"
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
title="Numbered list"
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
title="Quote"
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<Quote className="h-4 w-4" />
</ToolbarButton>
<span className="mx-1 h-4 w-px bg-zinc-200" />
{editor.isActive("link") ? (
<ToolbarButton
title="Remove link"
onClick={() => editor.chain().focus().unsetLink().run()}
>
<Link2Off className="h-4 w-4" />
</ToolbarButton>
) : (
<ToolbarButton title="Add link" onClick={setLink}>
<Link2 className="h-4 w-4" />
</ToolbarButton>
)}
<ToolbarButton title="Insert image by URL" onClick={addImage}>
<ImagePlus className="h-4 w-4" />
</ToolbarButton>
<span className="mx-1 h-4 w-px bg-zinc-200" />
<ToolbarButton
title="Undo"
onClick={() => editor.chain().focus().undo().run()}
>
<Undo2 className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
title="Redo"
onClick={() => editor.chain().focus().redo().run()}
>
<Redo2 className="h-4 w-4" />
</ToolbarButton>
</div>
);
}
export function NotesEditor({ name, initialHtml }: Props) {
// Tiptap v3 doesn't re-render on transactions by default, so mirror the
// HTML into state to keep the submitted hidden input current.
const [html, setHtml] = useState(initialHtml ?? "");
const editor = useEditor({
extensions: [
StarterKit.configure({ link: { openOnClick: false } }),
Image,
],
content: initialHtml ?? "",
immediatelyRender: false,
// Small editor: re-render per transaction so toolbar active states track.
shouldRerenderOnTransaction: true,
onUpdate: ({ editor }) => setHtml(editor.getHTML()),
editorProps: {
attributes: {
class:
"prose-notes min-h-40 max-w-none px-3 py-2 text-sm outline-none",
},
},
});
return (
<div className="rounded-lg border border-zinc-300 focus-within:border-zinc-500 focus-within:ring-2 focus-within:ring-zinc-200">
{editor && <Toolbar editor={editor} />}
<EditorContent editor={editor} />
<input type="hidden" name={name} value={html} readOnly />
</div>
);
}

View File

@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { Star, X } from "lucide-react";
type Props = {
name: string;
initialRating?: number | null;
};
/** 5-star picker submitting via a hidden input ("" = unrated). */
export function RatingInput({ name, initialRating }: Props) {
const [rating, setRating] = useState<number | null>(initialRating ?? null);
const [hover, setHover] = useState<number | null>(null);
const shown = hover ?? rating ?? 0;
return (
<div className="flex items-center gap-1">
<input type="hidden" name={name} value={rating ?? ""} readOnly />
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
title={`${n} star${n > 1 ? "s" : ""}`}
onClick={() => setRating(n)}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(null)}
className="p-0.5"
>
<Star
className={`h-5 w-5 transition-colors ${
n <= shown ? "fill-amber-400 text-amber-400" : "text-zinc-300"
}`}
/>
</button>
))}
{rating !== null && (
<button
type="button"
title="Clear rating"
onClick={() => setRating(null)}
className="ml-1 rounded p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}

View File

@ -0,0 +1,26 @@
import { Star } from "lucide-react";
type Props = {
rating: number | null;
className?: string;
};
/** Read-only 5-star display; renders nothing when unrated. */
export function RatingStars({ rating, className }: Props) {
if (!rating) return null;
return (
<span
className={`inline-flex items-center gap-0.5 ${className ?? ""}`}
aria-label={`Rated ${rating} out of 5`}
>
{[1, 2, 3, 4, 5].map((n) => (
<Star
key={n}
className={`h-3.5 w-3.5 ${
n <= rating ? "fill-amber-400 text-amber-400" : "text-zinc-300"
}`}
/>
))}
</span>
);
}

View File

@ -0,0 +1,7 @@
export function TagBadge({ name }: { name: string }) {
return (
<span className="inline-flex items-center rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600">
{name}
</span>
);
}

View File

@ -0,0 +1,24 @@
"use client";
import { Trash2 } from "lucide-react";
import { deleteLibraryItemAction } from "@/server/actions/library.actions";
export function DeleteLibraryItemButton({ libraryItemId }: { libraryItemId: string }) {
return (
<form
action={deleteLibraryItemAction}
onSubmit={(e) => {
if (!confirm("Remove this entry from your library?")) e.preventDefault();
}}
>
<input type="hidden" name="libraryItemId" value={libraryItemId} />
<button
type="submit"
title="Delete"
className="rounded-md p-1.5 text-zinc-400 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</button>
</form>
);
}

View File

@ -0,0 +1,158 @@
"use client";
import { useActionState } from "react";
import {
createLibraryItemAction,
updateLibraryItemAction,
type LibraryFormState,
} from "@/server/actions/library.actions";
export type LinkableItem = { id: string; title: string };
export type LibraryFormInitial = {
id: string;
itemId: string | null;
standaloneTitle: string | null;
mediaType: string | null;
condition: string | null;
notes: string | null;
};
type Props = {
/** Favorites that a physical copy can link to. */
linkableItems: LinkableItem[];
entry?: LibraryFormInitial;
};
const MEDIA_TYPES = [
"Book",
"DVD",
"Blu-ray",
"Vinyl",
"CD",
"Video game",
"Board game",
];
const inputClass =
"w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm outline-none focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200";
const labelClass = "mb-1 block text-sm font-medium";
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return (
<p className="mt-1 text-sm text-red-600" role="alert">
{message}
</p>
);
}
export function LibraryItemForm({ linkableItems, entry }: Props) {
const [state, formAction, pending] = useActionState<LibraryFormState, FormData>(
entry ? updateLibraryItemAction : createLibraryItemAction,
{}
);
const errors = state.fieldErrors ?? {};
return (
<form action={formAction} className="max-w-xl space-y-5">
{entry && <input type="hidden" name="libraryItemId" value={entry.id} />}
<div>
<label htmlFor="standaloneTitle" className={labelClass}>
Title
</label>
<input
id="standaloneTitle"
name="standaloneTitle"
type="text"
defaultValue={entry?.standaloneTitle ?? ""}
placeholder="Leave empty when linking to a favorite below"
className={inputClass}
/>
<FieldError message={errors.standaloneTitle} />
</div>
<div>
<label htmlFor="itemId" className={labelClass}>
Link to a favorite (optional)
</label>
<select
id="itemId"
name="itemId"
defaultValue={entry?.itemId ?? ""}
className={inputClass}
>
<option value=""> not linked </option>
{linkableItems.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
<FieldError message={errors.itemId} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="mediaType" className={labelClass}>
Media type
</label>
<input
id="mediaType"
name="mediaType"
type="text"
list="media-types"
defaultValue={entry?.mediaType ?? ""}
className={inputClass}
/>
<datalist id="media-types">
{MEDIA_TYPES.map((t) => (
<option key={t} value={t} />
))}
</datalist>
</div>
<div>
<label htmlFor="condition" className={labelClass}>
Condition
</label>
<input
id="condition"
name="condition"
type="text"
defaultValue={entry?.condition ?? ""}
placeholder="e.g. Like new"
className={inputClass}
/>
</div>
</div>
<div>
<label htmlFor="notes" className={labelClass}>
Notes
</label>
<textarea
id="notes"
name="notes"
rows={3}
defaultValue={entry?.notes ?? ""}
className={inputClass}
/>
</div>
{state.error && (
<p className="text-sm text-red-600" role="alert">
{state.error}
</p>
)}
<button
type="submit"
disabled={pending}
className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 disabled:opacity-50"
>
{pending ? "Saving…" : entry ? "Save changes" : "Add to library"}
</button>
</form>
);
}

View File

@ -0,0 +1,9 @@
import { icons, Sparkles, type LucideProps } from "lucide-react";
// Omit the SVG `name` attribute so ours may be null (category icons are optional).
type Props = Omit<LucideProps, "name"> & { name?: string | null };
export function CategoryIcon({ name, ...props }: Props) {
const Icon = (name && icons[name as keyof typeof icons]) || Sparkles;
return <Icon {...props} />;
}

View File

@ -0,0 +1,75 @@
import Link from "next/link";
import type { Category } from "@prisma/client";
import { Home, LibraryBig, Settings, Users, FolderCog, LogOut } from "lucide-react";
import { CategoryIcon } from "@/components/nav/CategoryIcon";
import { logoutAction } from "@/server/actions/auth.actions";
type Props = {
categories: Category[];
role: "ADMIN" | "FRIEND";
userName: string;
};
export function Sidebar({ categories, role, userName }: Props) {
const linkClass =
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100";
return (
<aside className="flex w-60 shrink-0 flex-col border-r border-zinc-200 bg-white">
<div className="px-4 py-5">
<Link href="/" className="text-lg font-semibold tracking-tight">
My Favorite Stuff
</Link>
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
<Link href="/" className={linkClass}>
<Home className="h-4 w-4" /> Dashboard
</Link>
<p className="px-3 pb-1 pt-4 text-xs font-medium uppercase tracking-wide text-zinc-400">
Categories
</p>
{categories.map((c) => (
<Link key={c.id} href={`/categories/${c.key}`} className={linkClass}>
<CategoryIcon name={c.icon} className="h-4 w-4" />
{c.label}
</Link>
))}
{role === "ADMIN" && (
<>
<p className="px-3 pb-1 pt-4 text-xs font-medium uppercase tracking-wide text-zinc-400">
Manage
</p>
<Link href="/library" className={linkClass}>
<LibraryBig className="h-4 w-4" /> My Library
</Link>
<Link href="/admin/categories" className={linkClass}>
<FolderCog className="h-4 w-4" /> Categories
</Link>
<Link href="/admin/users" className={linkClass}>
<Users className="h-4 w-4" /> Friends
</Link>
<Link href="/admin/settings" className={linkClass}>
<Settings className="h-4 w-4" /> Settings
</Link>
</>
)}
</nav>
<div className="flex items-center justify-between border-t border-zinc-200 px-4 py-3">
<span className="truncate text-sm text-zinc-500">{userName}</span>
<form action={logoutAction}>
<button
type="submit"
title="Sign out"
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900"
>
<LogOut className="h-4 w-4" />
</button>
</form>
</div>
</aside>
);
}

61
src/lib/env.ts Normal file
View File

@ -0,0 +1,61 @@
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().min(1),
AUTH_SECRET: z.string().min(1),
ADMIN_EMAIL: z.string().optional(),
ADMIN_PASSWORD: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
S3_REGION: z.string().optional(),
S3_BUCKET: z.string().optional(),
S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_PUBLIC_BASE_URL: z.string().optional(),
TMDB_API_KEY: z.string().optional(),
GOOGLE_BOOKS_API_KEY: z.string().optional(),
SPOTIFY_CLIENT_ID: z.string().optional(),
SPOTIFY_CLIENT_SECRET: z.string().optional(),
GOOGLE_PLACES_API_KEY: z.string().optional(),
YELP_API_KEY: z.string().optional(),
});
type Env = z.infer<typeof envSchema>;
function loadEnv(): Env {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Invalid environment configuration:\n${issues}`);
}
// Treat empty strings as absent for all optional vars.
const data = parsed.data;
for (const [k, v] of Object.entries(data)) {
if (v === "") (data as Record<string, string | undefined>)[k] = undefined;
}
return data;
}
// Validated lazily on first access: `next build` imports these modules inside
// the secret-free Docker builder stage, where required vars don't exist yet.
let cached: Env | undefined;
export const env: Env = new Proxy({} as Env, {
get(_target, prop) {
cached ??= loadEnv();
return cached[prop as keyof Env];
},
});
export function s3Configured(): boolean {
return Boolean(
env.S3_ENDPOINT &&
env.S3_BUCKET &&
env.S3_ACCESS_KEY_ID &&
env.S3_SECRET_ACCESS_KEY &&
env.S3_PUBLIC_BASE_URL
);
}

91
src/lib/field-schema.ts Normal file
View File

@ -0,0 +1,91 @@
import { z } from "zod";
/**
* Shape of one entry in Category.fieldSchema (a JSON column).
* Mirrors what prisma/seed.ts writes.
*/
export const fieldDefSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
type: z.enum(["text", "textarea", "number", "date", "url", "select"]),
required: z.boolean().optional(),
options: z.array(z.string()).optional(),
});
export type FieldDef = z.infer<typeof fieldDefSchema>;
/** Parses a Category.fieldSchema JSON value, dropping malformed entries. */
export function parseFieldSchema(json: unknown): FieldDef[] {
const result = z.array(z.unknown()).safeParse(json);
if (!result.success) return [];
return result.data.flatMap((entry) => {
const field = fieldDefSchema.safeParse(entry);
return field.success ? [field.data] : [];
});
}
export type CustomFieldValue = string | number;
export type CustomFields = Record<string, CustomFieldValue>;
/**
* Coerces raw form values (all strings) into a customFields JSON object
* according to the category's field schema. Unknown keys are ignored,
* empty values are omitted. Returns field-level errors keyed by field key.
*/
export function coerceCustomFields(
fields: FieldDef[],
raw: Record<string, string>
): { values: CustomFields; errors: Record<string, string> } {
const values: CustomFields = {};
const errors: Record<string, string> = {};
for (const field of fields) {
const rawValue = (raw[field.key] ?? "").trim();
if (!rawValue) {
if (field.required) errors[field.key] = `${field.label} is required.`;
continue;
}
switch (field.type) {
case "number": {
const num = Number(rawValue);
if (Number.isNaN(num)) {
errors[field.key] = `${field.label} must be a number.`;
} else {
values[field.key] = num;
}
break;
}
case "url": {
const url = z.url().safeParse(rawValue);
if (!url.success) {
errors[field.key] = `${field.label} must be a valid URL.`;
} else {
values[field.key] = rawValue;
}
break;
}
case "select": {
if (field.options && !field.options.includes(rawValue)) {
errors[field.key] = `${field.label} has an invalid option.`;
} else {
values[field.key] = rawValue;
}
break;
}
// text, textarea, date: store the trimmed string as-is
default:
values[field.key] = rawValue;
}
}
return { values, errors };
}
/** Reads stored customFields JSON into a display-safe record. */
export function readCustomFields(json: unknown): CustomFields {
const result = z
.record(z.string(), z.union([z.string(), z.number()]))
.safeParse(json);
return result.success ? result.data : {};
}

7
src/lib/images.ts Normal file
View File

@ -0,0 +1,7 @@
import { env } from "@/lib/env";
/** Builds the public URL for a stored cover image key, or null when unset/unconfigured. */
export function coverImageUrl(key: string | null | undefined): string | null {
if (!key || !env.S3_PUBLIC_BASE_URL) return null;
return `${env.S3_PUBLIC_BASE_URL.replace(/\/$/, "")}/${key}`;
}

45
src/lib/sanitize.ts Normal file
View File

@ -0,0 +1,45 @@
import sanitizeHtml from "sanitize-html";
/**
* Sanitizes Tiptap-generated notes HTML before it is stored.
* Allowlist matches the extensions enabled in the notes editor
* (StarterKit + Link + Image).
*/
export function sanitizeNotesHtml(html: string): string {
return sanitizeHtml(html, {
allowedTags: [
"p",
"br",
"h1",
"h2",
"h3",
"h4",
"strong",
"b",
"em",
"i",
"s",
"u",
"code",
"pre",
"blockquote",
"ul",
"ol",
"li",
"hr",
"a",
"img",
],
allowedAttributes: {
a: ["href", "target", "rel"],
img: ["src", "alt", "title", "width", "height"],
},
allowedSchemes: ["http", "https", "mailto"],
transformTags: {
a: sanitizeHtml.simpleTransform("a", {
rel: "noopener noreferrer nofollow",
target: "_blank",
}),
},
});
}

48
src/proxy.ts Normal file
View File

@ -0,0 +1,48 @@
import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import { authConfig } from "@/server/auth/auth.config";
const { auth } = NextAuth(authConfig);
/** Paths only the admin may reach. Server actions/route handlers re-check independently. */
const ADMIN_PREFIXES = [
"/admin",
"/library",
"/items/new",
"/api/integrations",
"/api/export",
"/api/uploads",
];
export default auth((req) => {
const { pathname } = req.nextUrl;
const session = req.auth;
const isLoggedIn = Boolean(session?.user);
const isLoginPage = pathname === "/login";
if (!isLoggedIn) {
if (isLoginPage) return NextResponse.next();
const loginUrl = new URL("/login", req.nextUrl);
return NextResponse.redirect(loginUrl);
}
if (isLoginPage) {
return NextResponse.redirect(new URL("/", req.nextUrl));
}
const isAdminPath =
ADMIN_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`)) ||
/^\/items\/[^/]+\/edit$/.test(pathname);
if (isAdminPath && session?.user?.role !== "ADMIN") {
return NextResponse.redirect(new URL("/", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
// Everything except NextAuth's own endpoints, static assets, and favicon.
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico).*)"],
};

View File

@ -0,0 +1,29 @@
"use server";
import { AuthError } from "next-auth";
import { signIn, signOut } from "@/server/auth/auth";
export type LoginState = { error?: string };
export async function loginAction(
_prev: LoginState,
formData: FormData
): Promise<LoginState> {
try {
await signIn("credentials", {
email: String(formData.get("email") ?? ""),
password: String(formData.get("password") ?? ""),
redirectTo: "/",
});
return {};
} catch (error) {
if (error instanceof AuthError) {
return { error: "Invalid email or password." };
}
throw error; // successful signIn throws a redirect — let it propagate
}
}
export async function logoutAction(): Promise<void> {
await signOut({ redirectTo: "/login" });
}

View File

@ -0,0 +1,220 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db/prisma";
import { fieldDefSchema, type FieldDef } from "@/lib/field-schema";
import {
assertAdmin,
ForbiddenError,
getViewerContext,
UnauthenticatedError,
} from "@/server/db/visibility";
export type CategoryFormState = {
error?: string;
fieldErrors?: Record<string, string>;
};
async function requireAdmin(): Promise<{ ok: true } | { failure: CategoryFormState }> {
try {
assertAdmin(await getViewerContext());
return { ok: true };
} catch (e) {
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
return { failure: { error: "You are not allowed to do that." } };
}
throw e;
}
}
const categorySchema = z.object({
label: z.string().trim().min(1, "Name is required.").max(100),
key: z
.string()
.trim()
.regex(
/^[a-z][a-z0-9-]*$/,
"Use lowercase letters, digits, and dashes; start with a letter."
)
.max(60),
icon: z
.string()
.trim()
.max(60)
.transform((v) => v || null),
isShared: z.boolean(),
});
function parseFieldSchemaInput(
raw: string
): { fields: FieldDef[] } | { error: string } {
let json: unknown;
try {
json = JSON.parse(raw || "[]");
} catch {
return { error: "Invalid field definitions." };
}
const parsed = z.array(fieldDefSchema).safeParse(json);
if (!parsed.success) {
return { error: "Each field needs a name, a key, and a type." };
}
const keys = parsed.data.map((f) => f.key);
if (new Set(keys).size !== keys.length) {
return { error: "Field keys must be unique." };
}
for (const field of parsed.data) {
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(field.key)) {
return { error: `Field key “${field.key}” must start with a letter and contain only letters, digits, dashes, or underscores.` };
}
if (field.type === "select" && (field.options ?? []).length === 0) {
return { error: `Add at least one option for “${field.label}”.` };
}
}
return { fields: parsed.data };
}
function readForm(formData: FormData) {
const base = categorySchema.safeParse({
label: String(formData.get("label") ?? ""),
key: String(formData.get("key") ?? ""),
icon: String(formData.get("icon") ?? ""),
isShared: formData.get("isShared") === "on",
});
const fields = parseFieldSchemaInput(String(formData.get("fieldSchema") ?? ""));
return { base, fields };
}
function stateFromZodError(error: z.ZodError): CategoryFormState {
const fieldErrors: Record<string, string> = {};
for (const issue of error.issues) {
const key = String(issue.path[0] ?? "");
if (key && !fieldErrors[key]) fieldErrors[key] = issue.message;
}
return { error: "Please fix the highlighted fields.", fieldErrors };
}
export async function createCategoryAction(
_prev: CategoryFormState,
formData: FormData
): Promise<CategoryFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const { base, fields } = readForm(formData);
if (!base.success) return stateFromZodError(base.error);
if ("error" in fields) return { error: fields.error };
const taken = await prisma.category.findUnique({ where: { key: base.data.key } });
if (taken) {
return {
error: "Please fix the highlighted fields.",
fieldErrors: { key: "A category with this key already exists." },
};
}
const maxSort = await prisma.category.aggregate({ _max: { sortOrder: true } });
await prisma.category.create({
data: {
...base.data,
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
fieldSchema: fields.fields,
},
});
revalidatePath("/", "layout");
redirect("/admin/categories");
}
export async function updateCategoryAction(
_prev: CategoryFormState,
formData: FormData
): Promise<CategoryFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const id = String(formData.get("categoryId") ?? "");
const existing = await prisma.category.findUnique({ where: { id } });
if (!existing) return { error: "Category not found." };
const { base, fields } = readForm(formData);
if (!base.success) return stateFromZodError(base.error);
if ("error" in fields) return { error: fields.error };
const taken = await prisma.category.findUnique({ where: { key: base.data.key } });
if (taken && taken.id !== id) {
return {
error: "Please fix the highlighted fields.",
fieldErrors: { key: "A category with this key already exists." },
};
}
await prisma.category.update({
where: { id },
data: { ...base.data, fieldSchema: fields.fields },
});
revalidatePath("/", "layout");
redirect("/admin/categories");
}
export async function toggleCategorySharedAction(formData: FormData): Promise<void> {
const gate = await requireAdmin();
if ("failure" in gate) throw new ForbiddenError();
const id = String(formData.get("categoryId") ?? "");
const category = await prisma.category.findUnique({ where: { id } });
if (!category) return;
await prisma.category.update({
where: { id },
data: { isShared: !category.isShared },
});
revalidatePath("/", "layout");
}
export async function moveCategoryAction(formData: FormData): Promise<void> {
const gate = await requireAdmin();
if ("failure" in gate) throw new ForbiddenError();
const id = String(formData.get("categoryId") ?? "");
const direction = String(formData.get("direction") ?? "");
if (direction !== "up" && direction !== "down") return;
// Normalized positions make the neighbor swap reliable even when
// sortOrder values contain gaps or duplicates.
const ordered = await prisma.category.findMany({
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { id: true },
});
const index = ordered.findIndex((c) => c.id === id);
const target = direction === "up" ? index - 1 : index + 1;
if (index === -1 || target < 0 || target >= ordered.length) return;
[ordered[index], ordered[target]] = [ordered[target], ordered[index]];
await prisma.$transaction(
ordered.map((c, position) =>
prisma.category.update({
where: { id: c.id },
data: { sortOrder: position + 1 },
})
)
);
revalidatePath("/", "layout");
}
export async function deleteCategoryAction(formData: FormData): Promise<void> {
const gate = await requireAdmin();
if ("failure" in gate) throw new ForbiddenError();
const id = String(formData.get("categoryId") ?? "");
const itemCount = await prisma.item.count({ where: { categoryId: id } });
if (itemCount > 0) return; // UI disables this; re-checked for direct POSTs
await prisma.category.deleteMany({ where: { id } });
revalidatePath("/", "layout");
}

View File

@ -0,0 +1,195 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db/prisma";
import {
assertAdmin,
ForbiddenError,
getViewerContext,
UnauthenticatedError,
} from "@/server/db/visibility";
import { coerceCustomFields, parseFieldSchema } from "@/lib/field-schema";
import { sanitizeNotesHtml } from "@/lib/sanitize";
export type ItemFormState = {
error?: string;
fieldErrors?: Record<string, string>;
};
const itemBaseSchema = z.object({
title: z.string().trim().min(1, "Title is required.").max(300),
categoryId: z.string().min(1, "Category is required."),
rating: z
.string()
.transform((v) => (v === "" ? null : Number(v)))
.pipe(z.union([z.literal(null), z.number().int().min(1).max(5)])),
description: z
.string()
.trim()
.max(500, "Keep the blurb under 500 characters.")
.transform((v) => v || null),
notesHtml: z.string().transform((v) => {
const clean = sanitizeNotesHtml(v).trim();
// Tiptap emits "<p></p>" for an empty document — treat as no notes.
return clean && clean !== "<p></p>" ? clean : null;
}),
tags: z.string().transform((v) =>
Array.from(
new Set(
v
.split(",")
.map((t) => t.trim())
.filter(Boolean)
)
)
),
});
const CUSTOM_FIELD_PREFIX = "cf.";
function readForm(formData: FormData) {
const base = itemBaseSchema.safeParse({
title: String(formData.get("title") ?? ""),
categoryId: String(formData.get("categoryId") ?? ""),
rating: String(formData.get("rating") ?? ""),
description: String(formData.get("description") ?? ""),
notesHtml: String(formData.get("notesHtml") ?? ""),
tags: String(formData.get("tags") ?? ""),
});
const rawCustom: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
if (key.startsWith(CUSTOM_FIELD_PREFIX) && typeof value === "string") {
rawCustom[key.slice(CUSTOM_FIELD_PREFIX.length)] = value;
}
}
return { base, rawCustom };
}
function stateFromZodError(error: z.ZodError): ItemFormState {
const fieldErrors: Record<string, string> = {};
for (const issue of error.issues) {
const key = String(issue.path[0] ?? "");
if (key && !fieldErrors[key]) fieldErrors[key] = issue.message;
}
return { error: "Please fix the highlighted fields.", fieldErrors };
}
async function requireAdmin(): Promise<
{ viewer: { userId: string } } | { failure: ItemFormState }
> {
try {
const viewer = await getViewerContext();
assertAdmin(viewer);
return { viewer };
} catch (e) {
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
return { failure: { error: "You are not allowed to do that." } };
}
throw e;
}
}
function tagWrites(tagNames: string[]) {
return tagNames.map((name) => ({
tag: { connectOrCreate: { where: { name }, create: { name } } },
}));
}
export async function createItemAction(
_prev: ItemFormState,
formData: FormData
): Promise<ItemFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const { base, rawCustom } = readForm(formData);
if (!base.success) return stateFromZodError(base.error);
const category = await prisma.category.findUnique({
where: { id: base.data.categoryId },
});
if (!category) return { error: "Unknown category." };
const custom = coerceCustomFields(parseFieldSchema(category.fieldSchema), rawCustom);
if (Object.keys(custom.errors).length > 0) {
return { error: "Please fix the highlighted fields.", fieldErrors: custom.errors };
}
const item = await prisma.item.create({
data: {
title: base.data.title,
categoryId: category.id,
rating: base.data.rating,
description: base.data.description,
notesHtml: base.data.notesHtml,
customFields: custom.values,
ownerId: gate.viewer.userId,
itemTags: { create: tagWrites(base.data.tags) },
},
});
revalidatePath("/", "layout");
redirect(`/items/${item.id}`);
}
export async function updateItemAction(
_prev: ItemFormState,
formData: FormData
): Promise<ItemFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const itemId = String(formData.get("itemId") ?? "");
const existing = await prisma.item.findUnique({ where: { id: itemId } });
if (!existing) return { error: "Item not found." };
const { base, rawCustom } = readForm(formData);
if (!base.success) return stateFromZodError(base.error);
const category = await prisma.category.findUnique({
where: { id: base.data.categoryId },
});
if (!category) return { error: "Unknown category." };
const custom = coerceCustomFields(parseFieldSchema(category.fieldSchema), rawCustom);
if (Object.keys(custom.errors).length > 0) {
return { error: "Please fix the highlighted fields.", fieldErrors: custom.errors };
}
await prisma.item.update({
where: { id: itemId },
data: {
title: base.data.title,
categoryId: category.id,
rating: base.data.rating,
description: base.data.description,
notesHtml: base.data.notesHtml,
customFields: custom.values,
itemTags: { deleteMany: {}, create: tagWrites(base.data.tags) },
},
});
revalidatePath("/", "layout");
redirect(`/items/${itemId}`);
}
export async function deleteItemAction(formData: FormData): Promise<void> {
const gate = await requireAdmin();
if ("failure" in gate) throw new ForbiddenError();
const itemId = String(formData.get("itemId") ?? "");
const item = await prisma.item.findUnique({
where: { id: itemId },
include: { category: true },
});
if (!item) return;
await prisma.item.delete({ where: { id: itemId } });
revalidatePath("/", "layout");
redirect(`/categories/${item.category.key}`);
}

View File

@ -0,0 +1,178 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db/prisma";
import {
assertAdmin,
ForbiddenError,
getViewerContext,
UnauthenticatedError,
} from "@/server/db/visibility";
export type LibraryFormState = {
error?: string;
fieldErrors?: Record<string, string>;
};
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.transform((v) => v || null);
const libraryItemSchema = z
.object({
itemId: optionalText(100),
standaloneTitle: optionalText(300),
mediaType: optionalText(100),
condition: optionalText(100),
notes: optionalText(5000),
})
.refine((data) => data.itemId || data.standaloneTitle, {
message: "Give the copy a title or link it to a favorite.",
path: ["standaloneTitle"],
});
async function requireAdmin(): Promise<
{ viewer: { userId: string } } | { failure: LibraryFormState }
> {
try {
const viewer = await getViewerContext();
assertAdmin(viewer);
return { viewer };
} catch (e) {
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
return { failure: { error: "You are not allowed to do that." } };
}
throw e;
}
}
function readForm(formData: FormData) {
return libraryItemSchema.safeParse({
itemId: String(formData.get("itemId") ?? ""),
standaloneTitle: String(formData.get("standaloneTitle") ?? ""),
mediaType: String(formData.get("mediaType") ?? ""),
condition: String(formData.get("condition") ?? ""),
notes: String(formData.get("notes") ?? ""),
});
}
function stateFromZodError(error: z.ZodError): LibraryFormState {
const fieldErrors: Record<string, string> = {};
for (const issue of error.issues) {
const key = String(issue.path[0] ?? "");
if (key && !fieldErrors[key]) fieldErrors[key] = issue.message;
}
return { error: "Please fix the highlighted fields.", fieldErrors };
}
export async function createLibraryItemAction(
_prev: LibraryFormState,
formData: FormData
): Promise<LibraryFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const parsed = readForm(formData);
if (!parsed.success) return stateFromZodError(parsed.error);
if (parsed.data.itemId) {
const item = await prisma.item.findUnique({
where: { id: parsed.data.itemId },
});
if (!item) return { error: "Linked favorite not found." };
}
await prisma.libraryItem.create({
data: { ...parsed.data, ownerId: gate.viewer.userId },
});
revalidatePath("/library");
redirect("/library");
}
export async function updateLibraryItemAction(
_prev: LibraryFormState,
formData: FormData
): Promise<LibraryFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const id = String(formData.get("libraryItemId") ?? "");
const existing = await prisma.libraryItem.findUnique({ where: { id } });
if (!existing) return { error: "Library entry not found." };
const parsed = readForm(formData);
if (!parsed.success) return stateFromZodError(parsed.error);
if (parsed.data.itemId) {
const item = await prisma.item.findUnique({
where: { id: parsed.data.itemId },
});
if (!item) return { error: "Linked favorite not found." };
}
await prisma.libraryItem.update({ where: { id }, data: parsed.data });
revalidatePath("/library");
redirect("/library");
}
export async function deleteLibraryItemAction(formData: FormData): Promise<void> {
const gate = await requireAdmin();
if ("failure" in gate) throw new ForbiddenError();
const id = String(formData.get("libraryItemId") ?? "");
await prisma.libraryItem.deleteMany({ where: { id } });
revalidatePath("/library");
}
export async function lendLibraryItemAction(formData: FormData): Promise<void> {
const gate = await requireAdmin();
if ("failure" in gate) throw new ForbiddenError();
const id = String(formData.get("libraryItemId") ?? "");
const borrowerName = String(formData.get("borrowerName") ?? "").trim();
if (!borrowerName) return;
const rawReturn = String(formData.get("expectedReturnDate") ?? "").trim();
const expectedReturnDate = rawReturn ? new Date(rawReturn) : null;
await prisma.libraryItem.updateMany({
where: { id, loanStatus: "AVAILABLE" },
data: {
loanStatus: "LENT_OUT",
borrowerName,
dateLent: new Date(),
expectedReturnDate:
expectedReturnDate && !Number.isNaN(expectedReturnDate.getTime())
? expectedReturnDate
: null,
},
});
revalidatePath("/library");
}
export async function returnLibraryItemAction(formData: FormData): Promise<void> {
const gate = await requireAdmin();
if ("failure" in gate) throw new ForbiddenError();
const id = String(formData.get("libraryItemId") ?? "");
await prisma.libraryItem.updateMany({
where: { id, loanStatus: "LENT_OUT" },
data: {
loanStatus: "AVAILABLE",
borrowerName: null,
dateLent: null,
expectedReturnDate: null,
},
});
revalidatePath("/library");
}

View File

@ -0,0 +1,208 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/server/db/prisma";
import { hashPassword, verifyPassword } from "@/server/auth/password";
import {
assertAdmin,
ForbiddenError,
getViewerContext,
UnauthenticatedError,
type ViewerContext,
} from "@/server/db/visibility";
export type UserFormState = {
error?: string;
success?: string;
fieldErrors?: Record<string, string>;
};
const PASSWORD_MIN = 8;
async function requireAdmin(): Promise<
{ viewer: ViewerContext } | { failure: UserFormState }
> {
try {
const viewer = await getViewerContext();
assertAdmin(viewer);
return { viewer };
} catch (e) {
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
return { failure: { error: "You are not allowed to do that." } };
}
throw e;
}
}
function stateFromZodError(error: z.ZodError): UserFormState {
const fieldErrors: Record<string, string> = {};
for (const issue of error.issues) {
const key = String(issue.path[0] ?? "");
if (key && !fieldErrors[key]) fieldErrors[key] = issue.message;
}
return { error: "Please fix the highlighted fields.", fieldErrors };
}
const createFriendSchema = z.object({
name: z.string().trim().min(1, "Name is required.").max(100),
email: z.email("Enter a valid email address.").max(200),
password: z
.string()
.min(PASSWORD_MIN, `Password needs at least ${PASSWORD_MIN} characters.`)
.max(200),
});
export async function createFriendAction(
_prev: UserFormState,
formData: FormData
): Promise<UserFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const parsed = createFriendSchema.safeParse({
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? "").toLowerCase(),
password: String(formData.get("password") ?? ""),
});
if (!parsed.success) return stateFromZodError(parsed.error);
const existing = await prisma.user.findUnique({
where: { email: parsed.data.email },
});
if (existing) {
return {
error: "Please fix the highlighted fields.",
fieldErrors: { email: "A user with this email already exists." },
};
}
await prisma.user.create({
data: {
name: parsed.data.name,
email: parsed.data.email,
passwordHash: await hashPassword(parsed.data.password),
role: "FRIEND",
},
});
revalidatePath("/admin/users");
return { success: `Added ${parsed.data.name}.` };
}
export async function resetFriendPasswordAction(
_prev: UserFormState,
formData: FormData
): Promise<UserFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const userId = String(formData.get("userId") ?? "");
const password = String(formData.get("password") ?? "");
if (password.length < PASSWORD_MIN) {
return {
error: `Password needs at least ${PASSWORD_MIN} characters.`,
};
}
// Only FRIEND passwords may be reset here; admins change their own in settings.
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user || user.role !== "FRIEND") {
return { error: "Friend account not found." };
}
await prisma.user.update({
where: { id: userId },
data: {
passwordHash: await hashPassword(password),
sessions: { deleteMany: {} },
},
});
revalidatePath("/admin/users");
return { success: `Password updated for ${user.name ?? user.email}.` };
}
export async function deleteFriendAction(formData: FormData): Promise<void> {
const gate = await requireAdmin();
if ("failure" in gate) throw new ForbiddenError();
const userId = String(formData.get("userId") ?? "");
// deleteMany + role filter: never removes admins, no-op when already gone.
await prisma.user.deleteMany({ where: { id: userId, role: "FRIEND" } });
revalidatePath("/admin/users");
}
const profileSchema = z.object({
name: z.string().trim().min(1, "Name is required.").max(100),
email: z.email("Enter a valid email address.").max(200),
});
export async function updateProfileAction(
_prev: UserFormState,
formData: FormData
): Promise<UserFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const parsed = profileSchema.safeParse({
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? "").toLowerCase(),
});
if (!parsed.success) return stateFromZodError(parsed.error);
const taken = await prisma.user.findUnique({
where: { email: parsed.data.email },
});
if (taken && taken.id !== gate.viewer.userId) {
return {
error: "Please fix the highlighted fields.",
fieldErrors: { email: "That email is already in use." },
};
}
await prisma.user.update({
where: { id: gate.viewer.userId },
data: parsed.data,
});
revalidatePath("/", "layout");
return { success: "Profile updated." };
}
export async function changePasswordAction(
_prev: UserFormState,
formData: FormData
): Promise<UserFormState> {
const gate = await requireAdmin();
if ("failure" in gate) return gate.failure;
const currentPassword = String(formData.get("currentPassword") ?? "");
const newPassword = String(formData.get("newPassword") ?? "");
if (newPassword.length < PASSWORD_MIN) {
return {
error: "Please fix the highlighted fields.",
fieldErrors: {
newPassword: `Password needs at least ${PASSWORD_MIN} characters.`,
},
};
}
const user = await prisma.user.findUnique({
where: { id: gate.viewer.userId },
});
if (!user || !(await verifyPassword(currentPassword, user.passwordHash))) {
return {
error: "Please fix the highlighted fields.",
fieldErrors: { currentPassword: "Current password is incorrect." },
};
}
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: await hashPassword(newPassword) },
});
return { success: "Password changed." };
}

View File

@ -0,0 +1,32 @@
import type { NextAuthConfig } from "next-auth";
/**
* Edge-safe part of the Auth.js config (no Prisma/bcrypt imports) so the
* middleware can decode the session JWT without pulling Node-only code.
*/
export const authConfig = {
session: {
strategy: "jwt",
maxAge: 7 * 24 * 60 * 60, // 7 days
},
pages: {
signIn: "/login",
},
providers: [],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role: "ADMIN" | "FRIEND" }).role;
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as "ADMIN" | "FRIEND";
}
return session;
},
},
} satisfies NextAuthConfig;

37
src/server/auth/auth.ts Normal file
View File

@ -0,0 +1,37 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import { prisma } from "@/server/db/prisma";
import { verifyPassword } from "@/server/auth/password";
import { authConfig } from "@/server/auth/auth.config";
const credentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const parsed = credentialsSchema.safeParse(credentials);
if (!parsed.success) return null;
const user = await prisma.user.findUnique({
where: { email: parsed.data.email.toLowerCase() },
});
if (!user) return null;
const valid = await verifyPassword(parsed.data.password, user.passwordHash);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role };
},
}),
],
});

View File

@ -0,0 +1,9 @@
import bcrypt from "bcryptjs";
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, 12);
}
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

7
src/server/db/prisma.ts Normal file
View File

@ -0,0 +1,7 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@ -0,0 +1,88 @@
import type { Prisma } from "@prisma/client";
import { auth } from "@/server/auth/auth";
import { prisma } from "@/server/db/prisma";
export type ViewerContext = { userId: string; role: "ADMIN" | "FRIEND" };
export class UnauthenticatedError extends Error {
constructor() {
super("UNAUTHENTICATED");
}
}
export class ForbiddenError extends Error {
constructor() {
super("FORBIDDEN");
}
}
/**
* Resolves the current viewer from the session, re-checking the user still
* exists in the DB so revoked accounts lose access immediately even though
* sessions are JWT-based.
*/
export async function getViewerContext(): Promise<ViewerContext> {
const session = await auth();
if (!session?.user?.id) throw new UnauthenticatedError();
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, role: true },
});
if (!user) throw new UnauthenticatedError();
return { userId: user.id, role: user.role };
}
export function assertAdmin(viewer: ViewerContext): void {
if (viewer.role !== "ADMIN") throw new ForbiddenError();
}
/** The ONLY place item visibility rules may be expressed. */
export function itemWhereForViewer(
viewer: ViewerContext,
extra?: Prisma.ItemWhereInput
): Prisma.ItemWhereInput {
const base: Prisma.ItemWhereInput =
viewer.role === "ADMIN" ? {} : { category: { isShared: true } };
return extra ? { AND: [base, extra] } : base;
}
/** Categories the viewer may browse (all for admin, shared-only for friends). */
export function categoryWhereForViewer(
viewer: ViewerContext
): Prisma.CategoryWhereInput {
return viewer.role === "ADMIN" ? {} : { isShared: true };
}
export async function findVisibleItems<
T extends Omit<Prisma.ItemFindManyArgs, "where"> & {
where?: Prisma.ItemWhereInput;
},
>(viewer: ViewerContext, args: T = {} as T): Promise<Prisma.ItemGetPayload<T>[]> {
// Cast: injecting the visibility `where` hides the args type from Prisma's
// own inference, so restate the payload type from the caller's args.
return prisma.item.findMany({
...args,
where: itemWhereForViewer(viewer, args.where),
}) as Promise<Prisma.ItemGetPayload<T>[]>;
}
/** Returns null when the item doesn't exist OR the viewer may not see it — callers show 404 either way. */
export async function findVisibleItem(viewer: ViewerContext, itemId: string) {
return prisma.item.findFirst({
where: itemWhereForViewer(viewer, { id: itemId }),
include: {
category: true,
itemTags: { include: { tag: true } },
},
});
}
export async function findVisibleCategories(viewer: ViewerContext) {
return prisma.category.findMany({
where: categoryWhereForViewer(viewer),
orderBy: { sortOrder: "asc" },
});
}

21
src/types/next-auth.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: "ADMIN" | "FRIEND";
} & DefaultSession["user"];
}
interface User {
role: "ADMIN" | "FRIEND";
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
role?: "ADMIN" | "FRIEND";
}
}