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:
parent
e60935b76e
commit
aa0d14b679
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
50
DEPLOY.md
Normal 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
39
Dockerfile
Normal 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
14
docker-entrypoint.sh
Normal 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
|
||||
@ -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
9009
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
175
prisma/migrations/20260706204929_init/migration.sql
Normal file
175
prisma/migrations/20260706204929_init/migration.sql
Normal 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;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
148
prisma/schema.prisma
Normal 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
162
prisma/seed.ts
Normal 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);
|
||||
});
|
||||
32
src/app/(app)/admin/categories/[id]/edit/page.tsx
Normal file
32
src/app/(app)/admin/categories/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/(app)/admin/categories/new/page.tsx
Normal file
15
src/app/(app)/admin/categories/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/app/(app)/admin/categories/page.tsx
Normal file
135
src/app/(app)/admin/categories/page.tsx
Normal 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't be deleted — remove or move their items first.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/app/(app)/admin/settings/page.tsx
Normal file
101
src/app/(app)/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/app/(app)/admin/users/page.tsx
Normal file
82
src/app/(app)/admin/users/page.tsx
Normal 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've marked as
|
||||
shared. They can'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>
|
||||
);
|
||||
}
|
||||
102
src/app/(app)/categories/[key]/page.tsx
Normal file
102
src/app/(app)/categories/[key]/page.tsx
Normal 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 A–Z</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>
|
||||
);
|
||||
}
|
||||
50
src/app/(app)/items/[id]/edit/page.tsx
Normal file
50
src/app/(app)/items/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/app/(app)/items/[id]/page.tsx
Normal file
130
src/app/(app)/items/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/app/(app)/items/new/page.tsx
Normal file
34
src/app/(app)/items/new/page.tsx
Normal 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
41
src/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/app/(app)/library/[id]/edit/page.tsx
Normal file
37
src/app/(app)/library/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/app/(app)/library/new/page.tsx
Normal file
21
src/app/(app)/library/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
src/app/(app)/library/page.tsx
Normal file
170
src/app/(app)/library/page.tsx
Normal 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
22
src/app/(app)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/(auth)/login/login-form.tsx
Normal file
54
src/app/(auth)/login/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/(auth)/login/page.tsx
Normal file
15
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/server/auth/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
358
src/components/admin/CategoryForm.tsx
Normal file
358
src/components/admin/CategoryForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/admin/DeleteCategoryButton.tsx
Normal file
43
src/components/admin/DeleteCategoryButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
src/components/admin/FriendForms.tsx
Normal file
165
src/components/admin/FriendForms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/admin/SettingsForms.tsx
Normal file
138
src/components/admin/SettingsForms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/items/DeleteItemButton.tsx
Normal file
25
src/components/items/DeleteItemButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/items/ItemCard.tsx
Normal file
67
src/components/items/ItemCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
src/components/items/ItemForm.tsx
Normal file
229
src/components/items/ItemForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/items/ItemGrid.tsx
Normal file
25
src/components/items/ItemGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
src/components/items/NotesEditor.tsx
Normal file
203
src/components/items/NotesEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/items/RatingInput.tsx
Normal file
49
src/components/items/RatingInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/items/RatingStars.tsx
Normal file
26
src/components/items/RatingStars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/components/items/TagBadge.tsx
Normal file
7
src/components/items/TagBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/library/DeleteLibraryItemButton.tsx
Normal file
24
src/components/library/DeleteLibraryItemButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/components/library/LibraryItemForm.tsx
Normal file
158
src/components/library/LibraryItemForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/components/nav/CategoryIcon.tsx
Normal file
9
src/components/nav/CategoryIcon.tsx
Normal 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} />;
|
||||
}
|
||||
75
src/components/nav/Sidebar.tsx
Normal file
75
src/components/nav/Sidebar.tsx
Normal 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
61
src/lib/env.ts
Normal 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
91
src/lib/field-schema.ts
Normal 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
7
src/lib/images.ts
Normal 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
45
src/lib/sanitize.ts
Normal 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
48
src/proxy.ts
Normal 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).*)"],
|
||||
};
|
||||
29
src/server/actions/auth.actions.ts
Normal file
29
src/server/actions/auth.actions.ts
Normal 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" });
|
||||
}
|
||||
220
src/server/actions/category.actions.ts
Normal file
220
src/server/actions/category.actions.ts
Normal 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");
|
||||
}
|
||||
195
src/server/actions/item.actions.ts
Normal file
195
src/server/actions/item.actions.ts
Normal 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}`);
|
||||
}
|
||||
178
src/server/actions/library.actions.ts
Normal file
178
src/server/actions/library.actions.ts
Normal 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");
|
||||
}
|
||||
208
src/server/actions/user.actions.ts
Normal file
208
src/server/actions/user.actions.ts
Normal 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." };
|
||||
}
|
||||
32
src/server/auth/auth.config.ts
Normal file
32
src/server/auth/auth.config.ts
Normal 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
37
src/server/auth/auth.ts
Normal 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 };
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
9
src/server/auth/password.ts
Normal file
9
src/server/auth/password.ts
Normal 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
7
src/server/db/prisma.ts
Normal 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;
|
||||
88
src/server/db/visibility.ts
Normal file
88
src/server/db/visibility.ts
Normal 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
21
src/types/next-auth.d.ts
vendored
Normal 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";
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user