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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
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",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "16.2.10",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"sanitize-html": "^2.17.5",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/sanitize-html": "^2.16.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.10",
|
"eslint-config-next": "16.2.10",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.23.0",
|
||||||
"typescript": "^5"
|
"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);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@ -7,14 +7,9 @@ const geistSans = Geist({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "My Favorite Stuff",
|
||||||
description: "Generated by create next app",
|
description: "Personal library of favorite things",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -23,11 +18,10 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="en" className={`${geistSans.variable} h-full antialiased`}>
|
||||||
lang="en"
|
<body className="min-h-full bg-zinc-50 font-sans text-zinc-900">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
{children}
|
||||||
>
|
</body>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
|
||||||
</html>
|
</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