Build yap-blog platform
This commit is contained in:
parent
ccdb0348c8
commit
32177b33f5
20
.env.example
Normal file
20
.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# --- Database -------------------------------------------------------------
|
||||
# Connection string used by the app, drizzle-kit, and the seed script.
|
||||
# Matches the Postgres service in docker-compose.yml.
|
||||
DATABASE_URL=postgresql://blog:blog@localhost:5434/blog
|
||||
|
||||
# Host port that docker-compose publishes Postgres on (container port 5432).
|
||||
POSTGRES_PORT=5434
|
||||
|
||||
# --- Initial administrator --------------------------------------------------
|
||||
# Read by `npm run db:seed`; only a scrypt hash of the password is stored.
|
||||
# Re-run the seed after changing these to update the stored credentials.
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-please
|
||||
|
||||
# --- Test databases (optional overrides) ------------------------------------
|
||||
# Both databases are created automatically by docker/initdb on the first
|
||||
# `docker compose up`. Unit/integration tests use TEST_DATABASE_URL and the
|
||||
# Playwright suite uses E2E_DATABASE_URL; both are wiped on every run.
|
||||
TEST_DATABASE_URL=postgresql://blog:blog@localhost:5434/blog_test
|
||||
E2E_DATABASE_URL=postgresql://blog:blog@localhost:5434/blog_e2e
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -30,8 +30,20 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
# env files (the template stays tracked)
|
||||
.env*
|
||||
!.env.example
|
||||
/env
|
||||
|
||||
# local agent/tool state
|
||||
/.claude/
|
||||
|
||||
# playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
# runtime image uploads (see src/lib/uploads.ts)
|
||||
/uploads/
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
|
|||
144
README.md
144
README.md
|
|
@ -1,36 +1,140 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Yap Blog — a small self-hosted blogging platform
|
||||
|
||||
## Getting Started
|
||||
A single-admin blogging platform built with **Next.js (App Router) + React + TypeScript**, backed by **PostgreSQL** via **Drizzle ORM**. Ships with fifteen switchable themes and twelve selectable body fonts, all configured from the admin area. Posts and static pages are written in Markdown, organized with tags, and managed through a session-authenticated admin area.
|
||||
|
||||
First, run the development server:
|
||||
  
|
||||
|
||||
## Quick start
|
||||
|
||||
Requirements: **Node.js ≥ 20**, **Docker** with the compose plugin, and free local ports **5434** (Postgres) and **3000** (dev server) — both configurable.
|
||||
|
||||
```bash
|
||||
# 1. Configuration (set ADMIN_PASSWORD to taste)
|
||||
cp .env.example .env
|
||||
|
||||
# 2. Database
|
||||
docker compose up -d
|
||||
|
||||
# 3. Dependencies
|
||||
npm install
|
||||
|
||||
# 4. Schema + demo content (admin credentials come from .env)
|
||||
npm run db:migrate
|
||||
npm run db:seed
|
||||
|
||||
# 5. Go
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Open <http://localhost:3000> for the public site and <http://localhost:3000/admin> for the admin area, signing in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` from your `.env`.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
> The seed is idempotent: the admin password hash is refreshed on every run (re-run it after changing `ADMIN_PASSWORD`), site settings are created only if missing, and demo content is only inserted into an empty database. It creates ~51 published posts (7 handwritten showcases plus generated topic filler so listings paginate to ~11 pages), 2 drafts, 9 tags, and 3 static pages.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## Commands
|
||||
|
||||
## Learn More
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Development server on :3000 |
|
||||
| `npm run build` | Production build |
|
||||
| `npm start` | Serve the production build |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | TypeScript compiler, no emit |
|
||||
| `npm test` | Unit + integration tests (Vitest, uses the `blog_test` DB) |
|
||||
| `npm run test:watch` | Vitest in watch mode |
|
||||
| `npm run test:e2e` | Production build, then Playwright E2E (uses the `blog_e2e` DB) |
|
||||
| `npm run test:all` | Both suites |
|
||||
| `npm run db:generate` | Generate a new migration from schema changes |
|
||||
| `npm run db:migrate` | Apply migrations |
|
||||
| `npm run db:seed` | Seed admin, settings, and demo content |
|
||||
| `npm run db:studio` | Drizzle Studio DB browser |
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
The `blog_test` and `blog_e2e` databases are created automatically the first time the Postgres volume initializes (`docker/initdb/`). Both are **wiped and re-migrated on every test run** — never point them at data you care about. If you created the volume with an older setup, recreate it with `docker compose down -v && docker compose up -d`.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Routes
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `/` | Configured home page: all posts, one tag's posts, or a static page |
|
||||
| `/posts` | All published posts, newest first, paginated (`?page=N`) |
|
||||
| `/posts/[slug]` | One published post |
|
||||
| `/tags/[slug]` | Published posts with that tag, paginated |
|
||||
| `/pages/[slug]` | One published static page |
|
||||
| `/admin` | Dashboard (auth required) |
|
||||
| `/admin/login` | Sign in |
|
||||
| `/admin/posts`, `/admin/posts/new`, `/admin/posts/[id]/edit`, `/admin/posts/[id]/preview` | Post management |
|
||||
| `/admin/pages`, … | Static-page management (same shape as posts) |
|
||||
| `/admin/settings` | Site title, header/footer text, theme, font, navigation, home-page mode, pagination & excerpt limits |
|
||||
|
||||
## Deploy on Vercel
|
||||
## Architecture
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
```
|
||||
src/
|
||||
├── app/ # Routes only — thin, no business logic
|
||||
│ ├── (public)/ # Public site, wrapped in header/sidebar/footer chrome
|
||||
│ │ ├── (home)/ # / with its own loading skeleton
|
||||
│ │ └── posts/(list)/ # /posts with its own loading skeleton
|
||||
│ ├── admin/login/ # Login (outside the guarded group)
|
||||
│ └── admin/(panel)/ # Guarded admin area (layout + every page re-check auth)
|
||||
├── actions/ # Server actions: auth, posts, pages, settings, preview
|
||||
├── components/ # public/ and admin/ UI + shared primitives (ui.tsx)
|
||||
├── db/ # Drizzle schema + connection pool
|
||||
├── lib/
|
||||
│ ├── auth/ # scrypt hashing, session store, cookie, DAL guards
|
||||
│ ├── services/ # All database access (posts, tags, pages, settings, home)
|
||||
│ └── … # slug, markdown, excerpt, pagination, validation, forms
|
||||
├── drizzle/ # Generated SQL migrations (committed)
|
||||
├── scripts/seed.ts # Idempotent seeding (also reused by the E2E setup)
|
||||
└── tests/ # unit/ · integration/ · e2e/
|
||||
```
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
### Key decisions
|
||||
|
||||
- **Server components + server actions, no API layer.** Public pages are React Server Components that call the service layer directly; admin mutations are server actions. There are no JSON route handlers because nothing consumes them — one less surface to validate and keep in sync. Route handlers can be added later for an RSS feed or a public API without touching the services.
|
||||
- **A service layer owns all SQL.** Files under `src/lib/services/` are the only place queries live. Pages and actions stay thin, and the integration tests exercise the exact code paths production uses.
|
||||
- **Everything renders dynamically** (`force-dynamic` in the root layout). All content is admin-editable at runtime, so pages read the DB per request — plenty fast for an MVP and never stale. The obvious next optimization is tag-based caching (`revalidateTag`) around settings/posts.
|
||||
- **Auth: opaque session tokens, scrypt passwords.** Login verifies against a scrypt hash (Node's built-in crypto; parameters encoded per-hash so they can be raised later). Sessions are 32-byte random tokens in an `httpOnly` `SameSite=Lax` cookie; the database stores only the SHA-256 of the token, so a leaked DB dump cannot forge cookies. Expired sessions are treated as absent and purged on login. A failed login costs one scrypt derivation whether or not the username exists, avoiding a user-enumeration timing signal.
|
||||
- **Protection is server-side at three layers**: the admin layout redirects, every admin page calls `requireAdmin()` (React-`cache()`d per request), and every mutating server action calls it again — an action invoked directly over HTTP without a valid session cookie redirects instead of mutating. There is deliberately no middleware-only check to rely on.
|
||||
- **Rich text editing, HTML storage.** Posts and pages are written in a WordPress-style WYSIWYG editor (Tiptap/ProseMirror): headings, bold/italic/underline/strike, inline code, links, lists, blockquotes, code blocks, tables with row/column controls, horizontal rules, undo/redo — plus inline images uploaded straight from the editor (toolbar button, drag-drop, or paste). Pasting plain-text **Markdown converts automatically** through the same remark pipeline used for seeding; pasting rich HTML uses ProseMirror's native handling. Bodies are stored as HTML and pass through one shared `rehype-sanitize` allowlist (GitHub schema + `<u>`/table scaffolding) **both on save and on render** — scripts, event handlers, and `javascript:` URLs cannot survive either path, and the editor is WYSIWYG against the same `.markdown-body` styles the public site uses, so what you see is literally what publishes.
|
||||
- **Slug policy.** Slugs are generated from titles (NFKD-normalized, diacritics stripped, hyphenated). A *generated* slug that collides is auto-suffixed (`-2`, `-3`, …); an *explicitly chosen* slug that collides is rejected with a field error — the author picked it, silently renaming it would surprise them. Uniqueness is also enforced by DB constraints, so a race between two requests ends in a caught constraint error, not a duplicate.
|
||||
- **Deletion safety is in the schema.** `settings.home_tag_id` / `home_page_id` are `ON DELETE SET NULL` and nav items are `ON DELETE CASCADE` from their page, so deleting a tag or page featured on the home page or navigation degrades gracefully (home falls back to the post list; the nav item disappears). Unpublishing a page hides its nav items until it is republished. A `CHECK` constraint guarantees a nav item points at exactly one of URL/page.
|
||||
- **HTTP 404 vs. streaming.** Slug routes (`/posts/[slug]`, `/tags/[slug]`, `/pages/[slug]`) have **no** loading boundary so unknown or draft content returns a real HTTP 404. The two listing routes have loading skeletons, which means an out-of-range `?page=` there streams a not-found UI with HTTP 200 plus a `noindex` meta tag (a "soft 404") — the deliberate trade-off for skeletons on the routes users actually wait on. Malformed pagination (`?page=abc`, `-1`, `1e9`) clamps to page 1.
|
||||
- **Theming.** `globals.css` maps raw palette values to semantic tokens (`--background`, `--ink`, `--link`, …) and exposes those to Tailwind via `@theme inline`; components only ever use semantic utilities (`bg-surface`, `text-ink-strong`). The admin **Settings → Appearance** section switches the whole site (public + admin) between fifteen themes by stamping `data-theme` on `<html>`; each theme is nothing but a token override block. Dark: **Solarized Dark** (default), **Dracula**, **Nord**, **Gruvbox Dark**, **Catppuccin Mocha**, **Tokyo Night**, **One Dark**, **Rosé Pine**, **Everforest Dark**, **Monokai**, **White on Black**. Light: **Solarized Light**, **Catppuccin Latte**, **GitHub Light**, **Black & White**. The two mono themes are deliberately pure grayscale. Selection color, blockquote borders, badges, and the browser `theme-color` all derive from tokens, so new themes need no component work. Adding a theme = one CSS block, one enum value in `src/db/schema.ts` (+ generated migration), and one entry in the `src/lib/themes.ts` registry — the `Record<Theme, …>` type makes a missing entry a compile error.
|
||||
- **Fonts.** The admin also picks a site-wide body font: sans — **Geist** (default), **Inter**, **Open Sans**, **Work Sans**, **Space Grotesk**, **Atkinson Hyperlegible**; serif — **Lora**, **Merriweather**, **Source Serif 4**, **EB Garamond**, **Playfair Display**; mono — **JetBrains Mono**. All are self-hosted via `next/font` (downloaded once at build time, no runtime Google requests). Only Geist is preloaded; the rest are declared `@font-face` rules the browser fetches solely when `data-font` on `<html>` makes one active. Tailwind's `font-sans` resolves through `--font-body`, which each `[data-font="…"]` block remaps. Code blocks always stay in Geist Mono. The settings form previews each option in its actual typeface.
|
||||
- **Accessibility.** Semantic landmarks, labelled navs, a skip-to-content link, visible `:focus-visible` rings, `aria-invalid`/`aria-describedby` wiring on form errors, an `Escape`-closable mobile menu, and alt-text support (with an explicit "decorative" convention) on featured images. Destructive admin actions confirm before submitting.
|
||||
|
||||
### Images
|
||||
|
||||
**Inline images** are uploaded from the editor to `POST /api/admin/uploads` (auth-required, 8 MB cap, MIME allowlist: PNG/JPEG/WebP/GIF/AVIF — SVG deliberately excluded). Files land in `./uploads/` (gitignored) under random UUID names — client filenames never touch the filesystem — and are served by the `GET /uploads/[name]` route handler with immutable cache headers (Next only serves `public/` files that existed at build time, hence the route). The filename pattern is validated on read, ruling out path traversal. Alt text is editable per image via the toolbar's **Alt** button when an image is selected.
|
||||
|
||||
**Featured images** accept either a pasted URL or the same upload flow via the Upload button next to the field. Rendering uses a plain `<img loading="lazy">` with an error fallback ("image unavailable") instead of `next/image`, because arbitrary admin-supplied URLs would require a wildcard `remotePatterns`, which turns the image optimizer into an open proxy.
|
||||
|
||||
Moving to object storage later: point `saveUploadedImage` (src/lib/uploads.ts) at S3/R2/MinIO instead of the local directory and return the bucket URL — the endpoint, editor, and schema stay unchanged. Restricting `next/image` to that bucket's hostname would then restore image optimization.
|
||||
|
||||
### Error handling
|
||||
|
||||
Public and admin groups have scoped `not-found.tsx`; `error.tsx` shows a generic retry card (details stay in server logs, correlated by digest); `global-error.tsx` catches root-layout failures (e.g. DB down) with a self-contained page. Server actions return typed field/form errors — constraint violations and unexpected exceptions surface as friendly messages, never stack traces or connection strings. `generateMetadata` failures fall back to defaults rather than crashing the page.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit** (`tests/unit/`): slugify + unique-slug suffixing, excerpt generation from stored HTML, both sanitizer pipelines (script stripping, event handlers, `javascript:` URLs, allowed editor marks), upload validation (MIME allowlist, size caps, filename generation), pagination parsing, URL validation.
|
||||
- **Integration** (`tests/integration/`, real Postgres): draft exclusion from public queries, reverse-chronological ordering and pagination, publish/unpublish `publishedAt` semantics, duplicate-slug handling on create/update, tag visibility and filtering, home-page mode fallbacks after deletion/unpublication, nav resolution, password hashing, session lifecycle.
|
||||
- **E2E** (`tests/e2e/`, Playwright against a production build): admin routes redirect anonymously; bad credentials rejected; a full editorial flow — login → compose in the rich editor (heading + bold via toolbar) → publish → public listing/post/tag pages → draft 404s → logout locks the admin again; an editor-capabilities flow — markdown paste conversion, `<script>` stripped from pasted content, inline image upload through the toolbar, the uploaded file actually served, and the upload endpoint returning 401 anonymously; and an appearance flow (themes + fonts asserted via `data-*` attributes and computed styles).
|
||||
|
||||
```bash
|
||||
npm test # unit + integration (~7s)
|
||||
npm run test:e2e # build + 3 E2E scenarios (~1 min)
|
||||
```
|
||||
|
||||
## Out of scope (by design)
|
||||
|
||||
Public registration, roles, comments, search, RSS, analytics, email, and deployment config are intentionally omitted.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- One administrator; credentials rotate via `.env` + re-seed.
|
||||
- Every request hits the database (no caching layer yet — see the caching note above).
|
||||
- Images are unoptimized `<img>` tags by design (see Images); uploads live on local disk, so a multi-instance deployment needs the object-storage swap described above.
|
||||
- Upload validation trusts the declared MIME type (plus a strict extension map and SVG exclusion); magic-byte sniffing would be the next hardening step.
|
||||
- Out-of-range pagination on listing routes is a soft 404 (real 404s everywhere else).
|
||||
- `window.confirm` guards destructive actions and quick link/alt prompts; styled dialogs would be nicer.
|
||||
- Bodies are stored as editor HTML. Content from databases seeded before this change (markdown source) renders as plain text — reseed demo databases rather than migrating them.
|
||||
|
|
|
|||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: blog
|
||||
POSTGRES_PASSWORD: blog
|
||||
POSTGRES_DB: blog
|
||||
ports:
|
||||
# Host port is configurable because 5432/5433 are often taken by local installs.
|
||||
- "${POSTGRES_PORT:-5434}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
# Creates the blog_test and blog_e2e databases on first startup of a fresh volume.
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U blog -d blog"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
5
docker/initdb/01-create-databases.sql
Normal file
5
docker/initdb/01-create-databases.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Extra databases used by the automated test suites.
|
||||
-- This script only runs the first time the Postgres volume is created;
|
||||
-- run `docker compose down -v && docker compose up -d` to recreate everything.
|
||||
CREATE DATABASE blog_test OWNER blog;
|
||||
CREATE DATABASE blog_e2e OWNER blog;
|
||||
15
drizzle.config.ts
Normal file
15
drizzle.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is not set. Copy .env.example to .env first.");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: { url: process.env.DATABASE_URL },
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
89
drizzle/0000_init.sql
Normal file
89
drizzle/0000_init.sql
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
CREATE TYPE "public"."content_status" AS ENUM('draft', 'published');--> statement-breakpoint
|
||||
CREATE TYPE "public"."home_mode" AS ENUM('posts', 'tag', 'page');--> statement-breakpoint
|
||||
CREATE TABLE "nav_items" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "nav_items_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"label" text NOT NULL,
|
||||
"url" text,
|
||||
"page_id" integer,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "nav_items_target_check" CHECK (("nav_items"."url" IS NULL) <> ("nav_items"."page_id" IS NULL))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pages" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "pages_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"title" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"body" text DEFAULT '' NOT NULL,
|
||||
"status" "content_status" DEFAULT 'draft' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pages_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "post_tags" (
|
||||
"post_id" integer NOT NULL,
|
||||
"tag_id" integer NOT NULL,
|
||||
CONSTRAINT "post_tags_post_id_tag_id_pk" PRIMARY KEY("post_id","tag_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "posts" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "posts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"title" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"body" text DEFAULT '' NOT NULL,
|
||||
"author_name" text NOT NULL,
|
||||
"featured_image_url" text,
|
||||
"featured_image_alt" text,
|
||||
"status" "content_status" DEFAULT 'draft' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
CONSTRAINT "posts_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "settings" (
|
||||
"id" integer PRIMARY KEY NOT NULL,
|
||||
"site_title" text DEFAULT 'My Blog' NOT NULL,
|
||||
"header_text" text DEFAULT '' NOT NULL,
|
||||
"footer_text" text DEFAULT '' NOT NULL,
|
||||
"posts_per_page" integer DEFAULT 10 NOT NULL,
|
||||
"excerpt_words" integer DEFAULT 40 NOT NULL,
|
||||
"home_mode" "home_mode" DEFAULT 'posts' NOT NULL,
|
||||
"home_tag_id" integer,
|
||||
"home_page_id" integer,
|
||||
CONSTRAINT "settings_single_row_check" CHECK ("settings"."id" = 1),
|
||||
CONSTRAINT "settings_posts_per_page_check" CHECK ("settings"."posts_per_page" BETWEEN 1 AND 50),
|
||||
CONSTRAINT "settings_excerpt_words_check" CHECK ("settings"."excerpt_words" BETWEEN 5 AND 200)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tags" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "tags_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
CONSTRAINT "tags_name_unique" UNIQUE("name"),
|
||||
CONSTRAINT "tags_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "users_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"username" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "nav_items" ADD CONSTRAINT "nav_items_page_id_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "post_tags" ADD CONSTRAINT "post_tags_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "post_tags" ADD CONSTRAINT "post_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD CONSTRAINT "settings_home_tag_id_tags_id_fk" FOREIGN KEY ("home_tag_id") REFERENCES "public"."tags"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD CONSTRAINT "settings_home_page_id_pages_id_fk" FOREIGN KEY ("home_page_id") REFERENCES "public"."pages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "post_tags_tag_id_idx" ON "post_tags" USING btree ("tag_id");--> statement-breakpoint
|
||||
CREATE INDEX "posts_status_published_at_idx" ON "posts" USING btree ("status","published_at");
|
||||
2
drizzle/0001_theme.sql
Normal file
2
drizzle/0001_theme.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
CREATE TYPE "public"."theme" AS ENUM('solarized-dark', 'solarized-light');--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD COLUMN "theme" "theme" DEFAULT 'solarized-dark' NOT NULL;
|
||||
6
drizzle/0002_themes-and-fonts.sql
Normal file
6
drizzle/0002_themes-and-fonts.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
CREATE TYPE "public"."font" AS ENUM('geist', 'inter', 'lora', 'merriweather', 'jetbrains-mono');--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'dracula';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'nord';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'gruvbox-dark';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'mono';--> statement-breakpoint
|
||||
ALTER TABLE "settings" ADD COLUMN "font" "font" DEFAULT 'geist' NOT NULL;
|
||||
16
drizzle/0003_more-themes-fonts.sql
Normal file
16
drizzle/0003_more-themes-fonts.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
ALTER TYPE "public"."font" ADD VALUE 'source-serif';--> statement-breakpoint
|
||||
ALTER TYPE "public"."font" ADD VALUE 'eb-garamond';--> statement-breakpoint
|
||||
ALTER TYPE "public"."font" ADD VALUE 'playfair-display';--> statement-breakpoint
|
||||
ALTER TYPE "public"."font" ADD VALUE 'open-sans';--> statement-breakpoint
|
||||
ALTER TYPE "public"."font" ADD VALUE 'work-sans';--> statement-breakpoint
|
||||
ALTER TYPE "public"."font" ADD VALUE 'atkinson-hyperlegible';--> statement-breakpoint
|
||||
ALTER TYPE "public"."font" ADD VALUE 'space-grotesk';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'mono-dark';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'catppuccin-mocha';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'catppuccin-latte';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'tokyo-night';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'one-dark';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'rose-pine';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'everforest-dark';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'monokai';--> statement-breakpoint
|
||||
ALTER TYPE "public"."theme" ADD VALUE 'github-light';
|
||||
671
drizzle/meta/0000_snapshot.json
Normal file
671
drizzle/meta/0000_snapshot.json
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
{
|
||||
"id": "f05fd91c-fca1-471d-a118-3b9f1f253c82",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.nav_items": {
|
||||
"name": "nav_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "nav_items_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"page_id": {
|
||||
"name": "page_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"nav_items_page_id_pages_id_fk": {
|
||||
"name": "nav_items_page_id_pages_id_fk",
|
||||
"tableFrom": "nav_items",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"nav_items_target_check": {
|
||||
"name": "nav_items_target_check",
|
||||
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pages": {
|
||||
"name": "pages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "pages_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "content_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"pages_slug_unique": {
|
||||
"name": "pages_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.post_tags": {
|
||||
"name": "post_tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"post_id": {
|
||||
"name": "post_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"post_tags_tag_id_idx": {
|
||||
"name": "post_tags_tag_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "tag_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"post_tags_post_id_posts_id_fk": {
|
||||
"name": "post_tags_post_id_posts_id_fk",
|
||||
"tableFrom": "post_tags",
|
||||
"tableTo": "posts",
|
||||
"columnsFrom": [
|
||||
"post_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"post_tags_tag_id_tags_id_fk": {
|
||||
"name": "post_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "post_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"post_tags_post_id_tag_id_pk": {
|
||||
"name": "post_tags_post_id_tag_id_pk",
|
||||
"columns": [
|
||||
"post_id",
|
||||
"tag_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.posts": {
|
||||
"name": "posts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "posts_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"author_name": {
|
||||
"name": "author_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"featured_image_url": {
|
||||
"name": "featured_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"featured_image_alt": {
|
||||
"name": "featured_image_alt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "content_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"published_at": {
|
||||
"name": "published_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"posts_status_published_at_idx": {
|
||||
"name": "posts_status_published_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "published_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"posts_slug_unique": {
|
||||
"name": "posts_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"site_title": {
|
||||
"name": "site_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'My Blog'"
|
||||
},
|
||||
"header_text": {
|
||||
"name": "header_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"footer_text": {
|
||||
"name": "footer_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"posts_per_page": {
|
||||
"name": "posts_per_page",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 10
|
||||
},
|
||||
"excerpt_words": {
|
||||
"name": "excerpt_words",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 40
|
||||
},
|
||||
"home_mode": {
|
||||
"name": "home_mode",
|
||||
"type": "home_mode",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'posts'"
|
||||
},
|
||||
"home_tag_id": {
|
||||
"name": "home_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"home_page_id": {
|
||||
"name": "home_page_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"settings_home_tag_id_tags_id_fk": {
|
||||
"name": "settings_home_tag_id_tags_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"home_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"settings_home_page_id_pages_id_fk": {
|
||||
"name": "settings_home_page_id_pages_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"home_page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"settings_single_row_check": {
|
||||
"name": "settings_single_row_check",
|
||||
"value": "\"settings\".\"id\" = 1"
|
||||
},
|
||||
"settings_posts_per_page_check": {
|
||||
"name": "settings_posts_per_page_check",
|
||||
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
|
||||
},
|
||||
"settings_excerpt_words_check": {
|
||||
"name": "settings_excerpt_words_check",
|
||||
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tags": {
|
||||
"name": "tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "tags_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"tags_slug_unique": {
|
||||
"name": "tags_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "users_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.content_status": {
|
||||
"name": "content_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"draft",
|
||||
"published"
|
||||
]
|
||||
},
|
||||
"public.home_mode": {
|
||||
"name": "home_mode",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"posts",
|
||||
"tag",
|
||||
"page"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
687
drizzle/meta/0001_snapshot.json
Normal file
687
drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
{
|
||||
"id": "292ee890-c58e-4035-8e11-cd68e8d49974",
|
||||
"prevId": "f05fd91c-fca1-471d-a118-3b9f1f253c82",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.nav_items": {
|
||||
"name": "nav_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "nav_items_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"page_id": {
|
||||
"name": "page_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"nav_items_page_id_pages_id_fk": {
|
||||
"name": "nav_items_page_id_pages_id_fk",
|
||||
"tableFrom": "nav_items",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"nav_items_target_check": {
|
||||
"name": "nav_items_target_check",
|
||||
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pages": {
|
||||
"name": "pages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "pages_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "content_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"pages_slug_unique": {
|
||||
"name": "pages_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.post_tags": {
|
||||
"name": "post_tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"post_id": {
|
||||
"name": "post_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"post_tags_tag_id_idx": {
|
||||
"name": "post_tags_tag_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "tag_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"post_tags_post_id_posts_id_fk": {
|
||||
"name": "post_tags_post_id_posts_id_fk",
|
||||
"tableFrom": "post_tags",
|
||||
"tableTo": "posts",
|
||||
"columnsFrom": [
|
||||
"post_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"post_tags_tag_id_tags_id_fk": {
|
||||
"name": "post_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "post_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"post_tags_post_id_tag_id_pk": {
|
||||
"name": "post_tags_post_id_tag_id_pk",
|
||||
"columns": [
|
||||
"post_id",
|
||||
"tag_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.posts": {
|
||||
"name": "posts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "posts_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"author_name": {
|
||||
"name": "author_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"featured_image_url": {
|
||||
"name": "featured_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"featured_image_alt": {
|
||||
"name": "featured_image_alt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "content_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"published_at": {
|
||||
"name": "published_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"posts_status_published_at_idx": {
|
||||
"name": "posts_status_published_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "published_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"posts_slug_unique": {
|
||||
"name": "posts_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"site_title": {
|
||||
"name": "site_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'My Blog'"
|
||||
},
|
||||
"header_text": {
|
||||
"name": "header_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"footer_text": {
|
||||
"name": "footer_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"posts_per_page": {
|
||||
"name": "posts_per_page",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 10
|
||||
},
|
||||
"excerpt_words": {
|
||||
"name": "excerpt_words",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 40
|
||||
},
|
||||
"home_mode": {
|
||||
"name": "home_mode",
|
||||
"type": "home_mode",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'posts'"
|
||||
},
|
||||
"home_tag_id": {
|
||||
"name": "home_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"home_page_id": {
|
||||
"name": "home_page_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "theme",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'solarized-dark'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"settings_home_tag_id_tags_id_fk": {
|
||||
"name": "settings_home_tag_id_tags_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"home_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"settings_home_page_id_pages_id_fk": {
|
||||
"name": "settings_home_page_id_pages_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"home_page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"settings_single_row_check": {
|
||||
"name": "settings_single_row_check",
|
||||
"value": "\"settings\".\"id\" = 1"
|
||||
},
|
||||
"settings_posts_per_page_check": {
|
||||
"name": "settings_posts_per_page_check",
|
||||
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
|
||||
},
|
||||
"settings_excerpt_words_check": {
|
||||
"name": "settings_excerpt_words_check",
|
||||
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tags": {
|
||||
"name": "tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "tags_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"tags_slug_unique": {
|
||||
"name": "tags_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "users_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.content_status": {
|
||||
"name": "content_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"draft",
|
||||
"published"
|
||||
]
|
||||
},
|
||||
"public.home_mode": {
|
||||
"name": "home_mode",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"posts",
|
||||
"tag",
|
||||
"page"
|
||||
]
|
||||
},
|
||||
"public.theme": {
|
||||
"name": "theme",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"solarized-dark",
|
||||
"solarized-light"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
710
drizzle/meta/0002_snapshot.json
Normal file
710
drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
{
|
||||
"id": "40b668fb-afde-426c-8368-80f1a624fd99",
|
||||
"prevId": "292ee890-c58e-4035-8e11-cd68e8d49974",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.nav_items": {
|
||||
"name": "nav_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "nav_items_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"page_id": {
|
||||
"name": "page_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"nav_items_page_id_pages_id_fk": {
|
||||
"name": "nav_items_page_id_pages_id_fk",
|
||||
"tableFrom": "nav_items",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"nav_items_target_check": {
|
||||
"name": "nav_items_target_check",
|
||||
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pages": {
|
||||
"name": "pages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "pages_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "content_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"pages_slug_unique": {
|
||||
"name": "pages_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.post_tags": {
|
||||
"name": "post_tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"post_id": {
|
||||
"name": "post_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"post_tags_tag_id_idx": {
|
||||
"name": "post_tags_tag_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "tag_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"post_tags_post_id_posts_id_fk": {
|
||||
"name": "post_tags_post_id_posts_id_fk",
|
||||
"tableFrom": "post_tags",
|
||||
"tableTo": "posts",
|
||||
"columnsFrom": [
|
||||
"post_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"post_tags_tag_id_tags_id_fk": {
|
||||
"name": "post_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "post_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"post_tags_post_id_tag_id_pk": {
|
||||
"name": "post_tags_post_id_tag_id_pk",
|
||||
"columns": [
|
||||
"post_id",
|
||||
"tag_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.posts": {
|
||||
"name": "posts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "posts_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"author_name": {
|
||||
"name": "author_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"featured_image_url": {
|
||||
"name": "featured_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"featured_image_alt": {
|
||||
"name": "featured_image_alt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "content_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"published_at": {
|
||||
"name": "published_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"posts_status_published_at_idx": {
|
||||
"name": "posts_status_published_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "published_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"posts_slug_unique": {
|
||||
"name": "posts_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"site_title": {
|
||||
"name": "site_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'My Blog'"
|
||||
},
|
||||
"header_text": {
|
||||
"name": "header_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"footer_text": {
|
||||
"name": "footer_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"posts_per_page": {
|
||||
"name": "posts_per_page",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 10
|
||||
},
|
||||
"excerpt_words": {
|
||||
"name": "excerpt_words",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 40
|
||||
},
|
||||
"home_mode": {
|
||||
"name": "home_mode",
|
||||
"type": "home_mode",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'posts'"
|
||||
},
|
||||
"home_tag_id": {
|
||||
"name": "home_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"home_page_id": {
|
||||
"name": "home_page_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "theme",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'solarized-dark'"
|
||||
},
|
||||
"font": {
|
||||
"name": "font",
|
||||
"type": "font",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'geist'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"settings_home_tag_id_tags_id_fk": {
|
||||
"name": "settings_home_tag_id_tags_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"home_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"settings_home_page_id_pages_id_fk": {
|
||||
"name": "settings_home_page_id_pages_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"home_page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"settings_single_row_check": {
|
||||
"name": "settings_single_row_check",
|
||||
"value": "\"settings\".\"id\" = 1"
|
||||
},
|
||||
"settings_posts_per_page_check": {
|
||||
"name": "settings_posts_per_page_check",
|
||||
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
|
||||
},
|
||||
"settings_excerpt_words_check": {
|
||||
"name": "settings_excerpt_words_check",
|
||||
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tags": {
|
||||
"name": "tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "tags_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"tags_slug_unique": {
|
||||
"name": "tags_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "users_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.content_status": {
|
||||
"name": "content_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"draft",
|
||||
"published"
|
||||
]
|
||||
},
|
||||
"public.font": {
|
||||
"name": "font",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"geist",
|
||||
"inter",
|
||||
"lora",
|
||||
"merriweather",
|
||||
"jetbrains-mono"
|
||||
]
|
||||
},
|
||||
"public.home_mode": {
|
||||
"name": "home_mode",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"posts",
|
||||
"tag",
|
||||
"page"
|
||||
]
|
||||
},
|
||||
"public.theme": {
|
||||
"name": "theme",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"solarized-dark",
|
||||
"solarized-light",
|
||||
"dracula",
|
||||
"nord",
|
||||
"gruvbox-dark",
|
||||
"mono"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
726
drizzle/meta/0003_snapshot.json
Normal file
726
drizzle/meta/0003_snapshot.json
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
{
|
||||
"id": "ad3939eb-f52e-4a81-8732-6bcede794ad2",
|
||||
"prevId": "40b668fb-afde-426c-8368-80f1a624fd99",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.nav_items": {
|
||||
"name": "nav_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "nav_items_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"page_id": {
|
||||
"name": "page_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"nav_items_page_id_pages_id_fk": {
|
||||
"name": "nav_items_page_id_pages_id_fk",
|
||||
"tableFrom": "nav_items",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"nav_items_target_check": {
|
||||
"name": "nav_items_target_check",
|
||||
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.pages": {
|
||||
"name": "pages",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "pages_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "content_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"pages_slug_unique": {
|
||||
"name": "pages_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.post_tags": {
|
||||
"name": "post_tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"post_id": {
|
||||
"name": "post_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"tag_id": {
|
||||
"name": "tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"post_tags_tag_id_idx": {
|
||||
"name": "post_tags_tag_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "tag_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"post_tags_post_id_posts_id_fk": {
|
||||
"name": "post_tags_post_id_posts_id_fk",
|
||||
"tableFrom": "post_tags",
|
||||
"tableTo": "posts",
|
||||
"columnsFrom": [
|
||||
"post_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"post_tags_tag_id_tags_id_fk": {
|
||||
"name": "post_tags_tag_id_tags_id_fk",
|
||||
"tableFrom": "post_tags",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"post_tags_post_id_tag_id_pk": {
|
||||
"name": "post_tags_post_id_tag_id_pk",
|
||||
"columns": [
|
||||
"post_id",
|
||||
"tag_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.posts": {
|
||||
"name": "posts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "posts_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"author_name": {
|
||||
"name": "author_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"featured_image_url": {
|
||||
"name": "featured_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"featured_image_alt": {
|
||||
"name": "featured_image_alt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "content_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'draft'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"published_at": {
|
||||
"name": "published_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"posts_status_published_at_idx": {
|
||||
"name": "posts_status_published_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "published_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"posts_slug_unique": {
|
||||
"name": "posts_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.settings": {
|
||||
"name": "settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"site_title": {
|
||||
"name": "site_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'My Blog'"
|
||||
},
|
||||
"header_text": {
|
||||
"name": "header_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"footer_text": {
|
||||
"name": "footer_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"posts_per_page": {
|
||||
"name": "posts_per_page",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 10
|
||||
},
|
||||
"excerpt_words": {
|
||||
"name": "excerpt_words",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 40
|
||||
},
|
||||
"home_mode": {
|
||||
"name": "home_mode",
|
||||
"type": "home_mode",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'posts'"
|
||||
},
|
||||
"home_tag_id": {
|
||||
"name": "home_tag_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"home_page_id": {
|
||||
"name": "home_page_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "theme",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'solarized-dark'"
|
||||
},
|
||||
"font": {
|
||||
"name": "font",
|
||||
"type": "font",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'geist'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"settings_home_tag_id_tags_id_fk": {
|
||||
"name": "settings_home_tag_id_tags_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "tags",
|
||||
"columnsFrom": [
|
||||
"home_tag_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"settings_home_page_id_pages_id_fk": {
|
||||
"name": "settings_home_page_id_pages_id_fk",
|
||||
"tableFrom": "settings",
|
||||
"tableTo": "pages",
|
||||
"columnsFrom": [
|
||||
"home_page_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"settings_single_row_check": {
|
||||
"name": "settings_single_row_check",
|
||||
"value": "\"settings\".\"id\" = 1"
|
||||
},
|
||||
"settings_posts_per_page_check": {
|
||||
"name": "settings_posts_per_page_check",
|
||||
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
|
||||
},
|
||||
"settings_excerpt_words_check": {
|
||||
"name": "settings_excerpt_words_check",
|
||||
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tags": {
|
||||
"name": "tags",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "tags_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"tags_name_unique": {
|
||||
"name": "tags_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"tags_slug_unique": {
|
||||
"name": "tags_slug_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "users_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.content_status": {
|
||||
"name": "content_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"draft",
|
||||
"published"
|
||||
]
|
||||
},
|
||||
"public.font": {
|
||||
"name": "font",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"geist",
|
||||
"inter",
|
||||
"lora",
|
||||
"merriweather",
|
||||
"jetbrains-mono",
|
||||
"source-serif",
|
||||
"eb-garamond",
|
||||
"playfair-display",
|
||||
"open-sans",
|
||||
"work-sans",
|
||||
"atkinson-hyperlegible",
|
||||
"space-grotesk"
|
||||
]
|
||||
},
|
||||
"public.home_mode": {
|
||||
"name": "home_mode",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"posts",
|
||||
"tag",
|
||||
"page"
|
||||
]
|
||||
},
|
||||
"public.theme": {
|
||||
"name": "theme",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"solarized-dark",
|
||||
"solarized-light",
|
||||
"dracula",
|
||||
"nord",
|
||||
"gruvbox-dark",
|
||||
"mono",
|
||||
"mono-dark",
|
||||
"catppuccin-mocha",
|
||||
"catppuccin-latte",
|
||||
"tokyo-night",
|
||||
"one-dark",
|
||||
"rose-pine",
|
||||
"everforest-dark",
|
||||
"monokai",
|
||||
"github-light"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
34
drizzle/meta/_journal.json
Normal file
34
drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1782951442209,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1782954277640,
|
||||
"tag": "0001_theme",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1783037024442,
|
||||
"tag": "0002_themes-and-fonts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1783037839786,
|
||||
"tag": "0003_more-themes-fonts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
// Pin the workspace root so stray lockfiles in parent directories
|
||||
// don't confuse Turbopack's project detection.
|
||||
turbopack: { root: __dirname },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
11714
package-lock.json
generated
Normal file
11714
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
42
package.json
42
package.json
|
|
@ -1,26 +1,60 @@
|
|||
{
|
||||
"name": "blog-scaffold",
|
||||
"name": "yap-blog",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"typecheck": "next typegen && tsc --noEmit -p tsconfig.typecheck.json",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:seed": "tsx scripts/seed.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "next build && tsx tests/e2e/setup-db.ts && playwright test",
|
||||
"test:all": "npm run test && npm run test:e2e"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^3.27.1",
|
||||
"@tiptap/extension-placeholder": "^3.27.1",
|
||||
"@tiptap/extension-table": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"next": "16.2.10",
|
||||
"pg": "^8.22.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"rehype-parse": "^9.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
playwright.config.ts
Normal file
30
playwright.config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import "dotenv/config";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const E2E_DATABASE_URL =
|
||||
process.env.E2E_DATABASE_URL || "postgresql://blog:blog@localhost:5434/blog_e2e";
|
||||
const PORT = 3100;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: `http://localhost:${PORT}`,
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: {
|
||||
// Requires a prior `next build`; `npm run test:e2e` chains both.
|
||||
command: `npx next start --port ${PORT}`,
|
||||
url: `http://localhost:${PORT}/posts`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
DATABASE_URL: E2E_DATABASE_URL,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
|
|
@ -1 +0,0 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
763
scripts/seed.ts
Normal file
763
scripts/seed.ts
Normal file
|
|
@ -0,0 +1,763 @@
|
|||
import "dotenv/config";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { count } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import {
|
||||
navItems,
|
||||
pages,
|
||||
postTags,
|
||||
posts,
|
||||
settings,
|
||||
tags,
|
||||
users,
|
||||
} from "../src/db/schema";
|
||||
import { hashPassword } from "../src/lib/auth/password";
|
||||
// Bodies below are authored in Markdown for maintainability, but the
|
||||
// database stores editor HTML — convert at insert time.
|
||||
import { renderMarkdown } from "../src/lib/markdown";
|
||||
import { slugify } from "../src/lib/slug";
|
||||
|
||||
const POST_BODIES = {
|
||||
hello: `Welcome to **Yap Blog**, a small blog that runs on Next.js, PostgreSQL, and Drizzle ORM.
|
||||
|
||||
This post exists so the front page is not empty on first boot. Log in at [/admin](/admin) to write your own, or delete everything here and start fresh.
|
||||
|
||||
## What you can do
|
||||
|
||||
- Write posts in Markdown with a live preview
|
||||
- Organize them with tags
|
||||
- Publish static pages and pin them to the navigation
|
||||
- Point the home page at the post list, a tag, or a page
|
||||
|
||||
> The best time to start a blog was ten years ago. The second-best time is tonight, after dark, in base03.
|
||||
|
||||
Happy writing!`,
|
||||
|
||||
solarized: `Every terminal eventually goes through a phase. Mine never left it.
|
||||
|
||||
[Solarized](https://ethanschoonover.com/solarized/) is a sixteen-color palette designed by Ethan Schoonover with *fixed contrast relationships* — the light and dark variants share the same four accent-friendly content tones, so switching themes never changes how loud your text feels.
|
||||
|
||||
## The dark half
|
||||
|
||||
| Name | Hex | Role |
|
||||
| ------ | --------- | ----------------------- |
|
||||
| base03 | \`#002b36\` | background |
|
||||
| base02 | \`#073642\` | highlighted background |
|
||||
| base01 | \`#586e75\` | secondary text |
|
||||
| base0 | \`#839496\` | body text |
|
||||
| base1 | \`#93a1a1\` | emphasized text |
|
||||
|
||||
The trick is that nothing is ever pure black or pure white. The background is a deep blue-green lagoon, and the text hovers above it like fog.
|
||||
|
||||
## Why it survives
|
||||
|
||||
Fashion cycles through editor themes the way it cycles through denim. Solarized persists because it was *engineered*, not just picked: every pair of tones was checked for perceptual contrast on calibrated displays in both CIELAB and by tired human eyes at 2 a.m.
|
||||
|
||||
This blog wears it out of gratitude.`,
|
||||
|
||||
markdown: `Everything on this site is written in Markdown and rendered server-side through a sanitizing pipeline. This post is the kitchen sink that proves it.
|
||||
|
||||
## Text
|
||||
|
||||
Plain paragraphs, **bold**, *italics*, ~~strikethrough~~, and \`inline code\` all work. So do [links](https://www.markdownguide.org/) and footnote-ish parentheticals (like this one).
|
||||
|
||||
## Lists
|
||||
|
||||
1. Ordered lists
|
||||
2. With multiple items
|
||||
- And nested bullets
|
||||
- Like these
|
||||
|
||||
## Code
|
||||
|
||||
\`\`\`ts
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.normalize("NFKD")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Quotes and rules
|
||||
|
||||
> A blockquote, styled with a Solarized comment-tone border.
|
||||
|
||||
---
|
||||
|
||||
## Tables
|
||||
|
||||
| Feature | Supported |
|
||||
| --------- | --------- |
|
||||
| GFM tables | yes |
|
||||
| Task lists | mostly |
|
||||
|
||||
## What does *not* work
|
||||
|
||||
Raw \`<script>\` tags are stripped by the sanitizer, event handlers never survive, and \`javascript:\` URLs are removed. Try it in the editor preview — the pipeline is identical.`,
|
||||
|
||||
drizzle: `Drizzle sits in a comfortable middle ground: more structure than raw SQL strings, far less machinery than a heavyweight ORM.
|
||||
|
||||
## Schema as the source of truth
|
||||
|
||||
The whole database lives in one TypeScript file. Columns, enums, foreign keys, check constraints — all plain declarations that \`drizzle-kit generate\` turns into versioned SQL migrations.
|
||||
|
||||
\`\`\`ts
|
||||
export const posts = pgTable("posts", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
status: contentStatusEnum("status").notNull().default("draft"),
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
## Queries that read like SQL
|
||||
|
||||
\`\`\`ts
|
||||
db.select()
|
||||
.from(posts)
|
||||
.where(eq(posts.status, "published"))
|
||||
.orderBy(desc(posts.publishedAt));
|
||||
\`\`\`
|
||||
|
||||
No magic query builder dialect to memorize — if you can read SQL, you can read this. And because every column is typed, renaming one breaks the build instead of production.`,
|
||||
|
||||
keyboard: `A blog admin you cannot drive from the keyboard is a blog you will slowly stop using.
|
||||
|
||||
## Small things that compound
|
||||
|
||||
- Every control on this site is reachable with <kbd>Tab</kbd> and visible when focused — the focus ring is Solarized blue, two pixels, unmissable.
|
||||
- The mobile menu closes on <kbd>Escape</kbd>.
|
||||
- Destructive buttons ask for confirmation in a dialog the keyboard already owns.
|
||||
- The first thing focus lands on after the page loads is a *skip to content* link.
|
||||
|
||||
## Semantic HTML is most of the work
|
||||
|
||||
Screen readers and keyboards both navigate by landmarks: \`<header>\`, \`<nav>\`, \`<main>\`, \`<aside>\`, \`<footer>\`. Get those right, label the navs, and half of accessibility falls out for free.
|
||||
|
||||
The other half is discipline about focus and contrast, which — conveniently — Solarized already solved.`,
|
||||
|
||||
postgres: `PostgreSQL is the only database this blog will ever need, and probably the only one you need too.
|
||||
|
||||
## Constraints are features
|
||||
|
||||
The schema pushes invariants into the database itself:
|
||||
|
||||
- \`UNIQUE\` on every slug — duplicate URLs are impossible, even under race conditions
|
||||
- \`CHECK ((url IS NULL) <> (page_id IS NULL))\` — a navigation item points at exactly one thing
|
||||
- \`ON DELETE SET NULL\` — deleting the tag your home page features cannot break the site
|
||||
|
||||
## The nice-to-haves you get for free
|
||||
|
||||
Transactional DDL means migrations either fully apply or fully roll back. \`TRUNCATE ... RESTART IDENTITY CASCADE\` resets the test database in one statement. And \`GENERATED ALWAYS AS IDENTITY\` ends the serial-vs-sequence confusion forever.
|
||||
|
||||
Boring technology, chosen deliberately, is a superpower.`,
|
||||
|
||||
writing: `The hardest part of maintaining a blog is not the software. It is the sitting down.
|
||||
|
||||
## Lower the activation energy
|
||||
|
||||
This is why the editor here does so little: a title, a textarea, a preview button. No blocks to arrange, no toolbar to negotiate with. The distance between *having a thought* and *publishing it* is four form fields.
|
||||
|
||||
## Drafts are a promise to yourself
|
||||
|
||||
Half-finished ideas go in as drafts. They never appear publicly — not on the front page, not in tag listings, not in the sidebar counts — but they sit in the admin list, quietly accusing, until you finish them.
|
||||
|
||||
Write badly, publish anyway, revise tomorrow. The edit button forgives everything.`,
|
||||
|
||||
draftIdeas: `Rough backlog — do not publish.
|
||||
|
||||
- [ ] Post about the session model (hashed tokens, 7-day expiry)
|
||||
- [ ] Compare rehype-sanitize schemas
|
||||
- [ ] Dark/light theme toggle using the semantic token layer
|
||||
- [ ] Benchmark: how many posts before pagination matters?`,
|
||||
|
||||
draftSecret: `This draft exists purely so the test suite can verify that draft posts and their tags never leak onto the public site.
|
||||
|
||||
If you can read this without being logged in, something is very wrong.`,
|
||||
} as const;
|
||||
|
||||
const PAGE_BODIES = {
|
||||
about: `**Yap Blog** is a demonstration blog for a small, self-hosted publishing platform built with Next.js, PostgreSQL, and Drizzle ORM.
|
||||
|
||||
## The stack
|
||||
|
||||
- **Next.js App Router** — server components and server actions, no client-side data fetching
|
||||
- **PostgreSQL** — one normalized schema, real constraints
|
||||
- **Drizzle ORM** — typed schema, generated SQL migrations
|
||||
- **Solarized Dark** — the only correct terminal palette, now on the web
|
||||
|
||||
## The author
|
||||
|
||||
The administrator account on this instance is created from environment variables at seed time. Only a scrypt hash of the password ever touches the database.
|
||||
|
||||
Want one of these yourself? Clone the repository, run \`docker compose up -d\`, and follow the README.`,
|
||||
|
||||
colophon: `This site is set in **Geist** with code in **Geist Mono**, colored exclusively with the sixteen Solarized values, and served by a single Next.js process talking to a single PostgreSQL database.
|
||||
|
||||
No analytics, no trackers, no cookies except the one that keeps the admin signed in.
|
||||
|
||||
Pages like this one are written in Markdown in the admin area and can be linked from the top navigation. Unpublish a page and any navigation item pointing at it vanishes until it returns.`,
|
||||
|
||||
roadmap: `Unpublished scratchpad for future work:
|
||||
|
||||
1. Image uploads to object storage
|
||||
2. RSS feed
|
||||
3. Full-text search with \`tsvector\`
|
||||
4. A second theme to prove the token layer works`,
|
||||
} as const;
|
||||
|
||||
function daysAgo(days: number): Date {
|
||||
return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
/*
|
||||
* Bulk demo posts
|
||||
* ---------------
|
||||
* The handful of posts above are handwritten showcases; the ~44 below are
|
||||
* generated filler so listings paginate realistically (~11 pages at the
|
||||
* seeded 5 posts/page). Each post gets a real title and a body assembled
|
||||
* deterministically from its topic's paragraph pool, so posts within a
|
||||
* topic share prose but no two bodies are identical.
|
||||
*/
|
||||
|
||||
type BulkTopic = {
|
||||
tagSlugs: string[];
|
||||
intros: string[];
|
||||
sections: Array<{ heading: string; body: string }>;
|
||||
closers: string[];
|
||||
code?: string;
|
||||
};
|
||||
|
||||
const BULK_TOPICS: Record<string, BulkTopic> = {
|
||||
design: {
|
||||
tagSlugs: ["design"],
|
||||
intros: [
|
||||
"Most interface problems are not creativity problems. They are restraint problems.",
|
||||
"You can learn a lot about a design system by looking at what it forbids.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: "Start from the reading experience",
|
||||
body: "A blog is a reading machine. Line length around seventy characters, generous leading, and a type scale with only a few stops will do more for the page than any amount of decoration. Everything else is negotiable; the paragraph is not.",
|
||||
},
|
||||
{
|
||||
heading: "Constraints make consistency cheap",
|
||||
body: "When the palette is sixteen colors and the spacing scale has eight steps, most decisions are already made. The remaining ones are small enough to make quickly and reverse painlessly. That is the entire trick behind design tokens.",
|
||||
},
|
||||
{
|
||||
heading: "Polish is mostly alignment",
|
||||
body: "If two edges almost line up, make them line up. If two grays are almost the same, make them the same. A screen full of *almosts* reads as sloppy even when nobody can say why, and a screen full of exact matches reads as intentional.",
|
||||
},
|
||||
{
|
||||
heading: "Design for the second visit",
|
||||
body: "First impressions matter less than the hundredth impression. Navigation that never moves, headers that never surprise, and links that always look like links are boring on day one and priceless on day ninety.",
|
||||
},
|
||||
],
|
||||
closers: [
|
||||
"None of this requires taste. It requires deciding once and then refusing to redecide every week.",
|
||||
"Boring, applied consistently, compounds into beautiful.",
|
||||
],
|
||||
},
|
||||
typescript: {
|
||||
tagSlugs: ["typescript"],
|
||||
intros: [
|
||||
"The compiler is the cheapest reviewer you will ever hire.",
|
||||
"Types are documentation that cannot drift out of date.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: "Model states, not fields",
|
||||
body: "A form that is either *editing*, *saving*, or *failed* should be a union of three shapes, not five booleans that can contradict each other. Once illegal states cannot be represented, half the defensive code deletes itself.",
|
||||
},
|
||||
{
|
||||
heading: "Let inference do the typing",
|
||||
body: "Annotate the boundaries — function arguments, module exports, API responses — and let inference handle everything in between. Code with type noise on every line is as hard to read as code with none.",
|
||||
},
|
||||
{
|
||||
heading: "Parse at the edges",
|
||||
body: "Data that enters the system through a form, a request, or an environment variable gets parsed once, immediately, into a known shape. Everything downstream then works with honest types instead of optimistic assertions.",
|
||||
},
|
||||
{
|
||||
heading: "Strictness is a one-way door",
|
||||
body: "Turning strict mode on late in a project is a week of archaeology. Turning it on from the first commit costs nothing. There is no third option where it stays off and the codebase stays healthy.",
|
||||
},
|
||||
],
|
||||
closers: [
|
||||
"The goal is not type gymnastics. The goal is deleting the tests you no longer need.",
|
||||
"Every `any` is a small loan against future debugging time, at a terrible interest rate.",
|
||||
],
|
||||
code: '```ts\ntype SaveState =\n | { status: "editing" }\n | { status: "saving" }\n | { status: "failed"; error: string };\n```',
|
||||
},
|
||||
postgres: {
|
||||
tagSlugs: ["postgresql"],
|
||||
intros: [
|
||||
"The database outlives every framework that talks to it.",
|
||||
"Ask the database to enforce the rule, and it will never forget to.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: "Constraints beat conventions",
|
||||
body: "A unique index does not care that two requests arrived in the same millisecond. A check constraint does not care that a new teammate skipped the onboarding doc. Rules that live in the schema are the only rules that hold under concurrency.",
|
||||
},
|
||||
{
|
||||
heading: "EXPLAIN before you optimize",
|
||||
body: "Most slow queries are slow for one boring reason: a sequential scan that should be an index scan. Reading the plan takes a minute; guessing takes an afternoon and usually lands on the wrong fix.",
|
||||
},
|
||||
{
|
||||
heading: "Migrations are code review for your data model",
|
||||
body: "Generated SQL sitting in a diff is the moment to catch the nullable column that should not be nullable. Once it ships, the mistake acquires rows, and rows have gravity.",
|
||||
},
|
||||
{
|
||||
heading: "Use fewer databases than you think you need",
|
||||
body: "Postgres will happily be your queue, your cache, your search index, and your JSON store while your project earns the traffic that justifies specialized tools. One backup, one connection string, one thing to learn deeply.",
|
||||
},
|
||||
],
|
||||
closers: [
|
||||
"Boring technology is a compliment, and Postgres is the most complimented software alive.",
|
||||
"Data quality is not a cleanup task. It is a schema design decision from day one.",
|
||||
],
|
||||
code: "```sql\nALTER TABLE posts\n ADD CONSTRAINT posts_slug_format\n CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$');\n```",
|
||||
},
|
||||
nextjs: {
|
||||
tagSlugs: ["nextjs"],
|
||||
intros: [
|
||||
"The server is a better place for most of the work than we spent a decade pretending it was.",
|
||||
"Every kilobyte of JavaScript you do not ship is a feature.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: "Server components change the default",
|
||||
body: "Data fetching next to rendering, no client bundle cost, no loading spinner choreography. The client is reserved for the parts that are genuinely interactive, which in a blog is a menu button and a couple of forms.",
|
||||
},
|
||||
{
|
||||
heading: "Server actions are just functions",
|
||||
body: "A mutation is a typed function call that happens to cross the network. No endpoint naming committee, no JSON envelope bikeshed, no client-side fetch wrapper. Validate at the top, authorize before anything else, return field errors as data.",
|
||||
},
|
||||
{
|
||||
heading: "Layouts are an ownership boundary",
|
||||
body: "The chrome fetches what the chrome needs; the page fetches what the page needs. Route groups let two trees share a URL space without sharing chrome, which is exactly how an admin panel wants to live inside a public site.",
|
||||
},
|
||||
{
|
||||
heading: "Streaming needs a status-code budget",
|
||||
body: "The moment the shell flushes, the status code is spent. Routes that can 404 should resolve before streaming begins; routes that never 404 can stream skeletons freely. Decide per route, not per app.",
|
||||
},
|
||||
],
|
||||
closers: [
|
||||
"The mental model is old: render on the server, enhance where needed. It just has good tooling now.",
|
||||
"Fewer moving parts on the client means fewer places for the bug to hide.",
|
||||
],
|
||||
code: '```tsx\nexport default async function Page() {\n const posts = await listPublishedPosts({ page: 1, perPage: 10 });\n return <PostList posts={posts.items} />;\n}\n```',
|
||||
},
|
||||
writing: {
|
||||
tagSlugs: ["writing"],
|
||||
intros: [
|
||||
"The blank page is not the enemy. The closed editor is.",
|
||||
"Nobody is waiting for your post, which is exactly why you can publish it.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: "Lower the stakes on purpose",
|
||||
body: "A post is not a thesis. Three paragraphs that say one true thing beat three thousand words that circle four maybe-true things. If it grows, it grows in the editor, not in your head.",
|
||||
},
|
||||
{
|
||||
heading: "Write for one specific reader",
|
||||
body: "Pick a person — a colleague, a past version of yourself, the next stranger with your exact bug — and explain it to them. Prose addressed to everyone lands on no one.",
|
||||
},
|
||||
{
|
||||
heading: "Endings are allowed to be abrupt",
|
||||
body: "You do not owe the reader a summary of what they just read. When the point has been made, stop. The best closing line is usually the one you almost deleted for being too plain.",
|
||||
},
|
||||
{
|
||||
heading: "Momentum beats inspiration",
|
||||
body: "A mediocre paragraph on Tuesday makes a good paragraph possible on Wednesday. The drafts folder is not a graveyard; it is a compost heap, and compost is how gardens work.",
|
||||
},
|
||||
],
|
||||
closers: [
|
||||
"Publish it. You can be embarrassed and findable, or polished and imaginary.",
|
||||
"The archive you envy is just someone else's pile of Tuesdays.",
|
||||
],
|
||||
},
|
||||
tooling: {
|
||||
tagSlugs: ["tooling"],
|
||||
intros: [
|
||||
"Good tooling is invisible until you work somewhere without it.",
|
||||
"Every manual step is a future incident report.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: "Scripts are institutional memory",
|
||||
body: "The deploy ritual that lives in someone's shell history is one resignation away from being lost. The same ritual as a script in the repo is documentation that executes.",
|
||||
},
|
||||
{
|
||||
heading: "Make the fast path the right path",
|
||||
body: "If linting runs on save and tests run in a keystroke, they happen constantly. If they require remembering a command with four flags, they happen the night before release. Friction decides behavior more than policy does.",
|
||||
},
|
||||
{
|
||||
heading: "Update tools on a schedule, not in a panic",
|
||||
body: "Small weekly bumps fail in small ways. The eighteen-month mega-upgrade fails in ways that get their own retrospective document and a nickname.",
|
||||
},
|
||||
],
|
||||
closers: [
|
||||
"The best developer experience improvements are the ones nobody thanks you for, because nobody notices the problem is gone.",
|
||||
"Sharpen the saw, but also: stop carrying the saw everywhere by hand.",
|
||||
],
|
||||
code: '```json\n{\n "scripts": {\n "check": "npm run lint && npm run typecheck && npm test"\n }\n}\n```',
|
||||
},
|
||||
accessibility: {
|
||||
tagSlugs: ["accessibility", "design"],
|
||||
intros: [
|
||||
"Accessibility is not a feature you add. It is damage you stop doing.",
|
||||
"The keyboard user is not an edge case; they are the test you can run yourself, today.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: "Semantics do the heavy lifting",
|
||||
body: "A real button, a real nav, a real heading hierarchy: assistive tech understands these for free. Recreating them from divs means re-implementing the browser badly, one ARIA attribute at a time.",
|
||||
},
|
||||
{
|
||||
heading: "Focus must be visible, always",
|
||||
body: "Removing the focus ring because it 'looks busy' is unplugging the only steering wheel some users have. Style it boldly instead — a confident ring reads as designed, not accidental.",
|
||||
},
|
||||
{
|
||||
heading: "Alt text is editorial, not technical",
|
||||
body: "The question is not 'what pixels are here' but 'what would the sighted reader take away'. Sometimes that is a description; sometimes it is an empty string, because the image was decoration all along.",
|
||||
},
|
||||
],
|
||||
closers: [
|
||||
"Tab through your site once a week. It costs ninety seconds and finds bugs your test suite cannot see.",
|
||||
"Accessible sites are faster, simpler, and easier to test. The virtue is a side effect of the quality.",
|
||||
],
|
||||
},
|
||||
performance: {
|
||||
tagSlugs: ["performance", "nextjs"],
|
||||
intros: [
|
||||
"Performance is a feature users notice by its absence.",
|
||||
"The profiler has ended more arguments than any style guide ever will.",
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
heading: "Measure, then touch",
|
||||
body: "The slow part is never where intuition points. Ten minutes with real timings regularly reveals that the 'expensive render' is fine and the innocent-looking query runs four hundred times.",
|
||||
},
|
||||
{
|
||||
heading: "The cheapest work is the skipped kind",
|
||||
body: "Before making a request faster, ask whether it needs to happen. Caching, deduplication, and pagination are not optimizations; they are decisions not to do the work at all.",
|
||||
},
|
||||
{
|
||||
heading: "Budgets keep you honest",
|
||||
body: "A page-weight budget turns 'it feels slower lately' into 'we crossed 200 KB in March'. Numbers with thresholds get defended; vibes get eroded one dependency at a time.",
|
||||
},
|
||||
],
|
||||
closers: [
|
||||
"Fast software is mostly the accumulation of small refusals.",
|
||||
"Users cannot tell you the site is slow. They just come back less often.",
|
||||
],
|
||||
code: "```ts\nconst [countRows, itemRows] = await Promise.all([\n countQuery,\n pageQuery.limit(perPage).offset(offset),\n]);\n```",
|
||||
},
|
||||
};
|
||||
|
||||
const BULK_POSTS: Array<{ title: string; topic: keyof typeof BULK_TOPICS }> = [
|
||||
{ title: "Contrast is a budget", topic: "design" },
|
||||
{ title: "Narrowing is the whole game", topic: "typescript" },
|
||||
{ title: "Indexes I actually use", topic: "postgres" },
|
||||
{ title: "Server components, one year in", topic: "nextjs" },
|
||||
{ title: "Write the middle first", topic: "writing" },
|
||||
{ title: "My terminal is my IDE", topic: "tooling" },
|
||||
{ title: "Focus rings are not optional", topic: "accessibility" },
|
||||
{ title: "Measure before you memoize", topic: "performance" },
|
||||
{ title: "Whitespace does the heavy lifting", topic: "design" },
|
||||
{ title: "satisfies changed how I write configs", topic: "typescript" },
|
||||
{ title: "CHECK constraints are cheap insurance", topic: "postgres" },
|
||||
{ title: "Streaming is a UX decision", topic: "nextjs" },
|
||||
{ title: "Short posts are allowed", topic: "writing" },
|
||||
{ title: "Dotfiles as documentation", topic: "tooling" },
|
||||
{ title: "Alt text is an editorial skill", topic: "accessibility" },
|
||||
{ title: "The fastest request is no request", topic: "performance" },
|
||||
{ title: "Designing empty states first", topic: "design" },
|
||||
{ title: "Discriminated unions for UI state", topic: "typescript" },
|
||||
{ title: "Explaining EXPLAIN to myself", topic: "postgres" },
|
||||
{ title: "Route groups keep layouts honest", topic: "nextjs" },
|
||||
{ title: "Editing is deleting", topic: "writing" },
|
||||
{ title: "The linter argues so we don't have to", topic: "tooling" },
|
||||
{ title: "Keyboard first, mouse second", topic: "accessibility" },
|
||||
{ title: "Lazy loading below the fold", topic: "performance" },
|
||||
{ title: "The case for boring navigation", topic: "design" },
|
||||
{ title: "The readonly habit", topic: "typescript" },
|
||||
{ title: "Migrations without fear", topic: "postgres" },
|
||||
{ title: "Caching is a contract", topic: "nextjs" },
|
||||
{ title: "Keep a someday file", topic: "writing" },
|
||||
{ title: "Scripts over memory", topic: "tooling" },
|
||||
{ title: "Semantic HTML is free accessibility", topic: "accessibility" },
|
||||
{ title: "Budgets make performance a feature", topic: "performance" },
|
||||
{ title: "Color tokens before color choices", topic: "design" },
|
||||
{ title: "Generics you can actually read", topic: "typescript" },
|
||||
{ title: "The case against clever SQL", topic: "postgres" },
|
||||
{ title: "Server actions without the footguns", topic: "nextjs" },
|
||||
{ title: "Publish on a schedule, not a mood", topic: "writing" },
|
||||
{ title: "Slow tools teach bad habits", topic: "tooling" },
|
||||
{ title: "Typography defaults worth stealing", topic: "design" },
|
||||
{ title: "Parsing, not validating, in practice", topic: "typescript" },
|
||||
{ title: "Timestamps, time zones, and regret", topic: "postgres" },
|
||||
{ title: "The app router mental model", topic: "nextjs" },
|
||||
{ title: "Titles are promises", topic: "writing" },
|
||||
{ title: "Small screens are the honest ones", topic: "design" },
|
||||
];
|
||||
|
||||
function buildBulkBody(topicKey: keyof typeof BULK_TOPICS, index: number): string {
|
||||
const topic = BULK_TOPICS[topicKey];
|
||||
const intro = topic.intros[index % topic.intros.length];
|
||||
const first = topic.sections[index % topic.sections.length];
|
||||
let second = topic.sections[(index + 2) % topic.sections.length];
|
||||
if (second === first) {
|
||||
second = topic.sections[(index + 1) % topic.sections.length];
|
||||
}
|
||||
const closer = topic.closers[index % topic.closers.length];
|
||||
|
||||
const parts = [intro, `## ${first.heading}`, first.body];
|
||||
if (topic.code && index % 2 === 0) parts.push(topic.code);
|
||||
parts.push(`## ${second.heading}`, second.body, closer);
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
export async function seed(databaseUrl: string, log: (msg: string) => void = () => {}) {
|
||||
const pool = new Pool({ connectionString: databaseUrl, max: 3 });
|
||||
const db = drizzle(pool);
|
||||
|
||||
try {
|
||||
// --- Administrator (from env; hash updated on every run) ---------------
|
||||
const username = process.env.ADMIN_USERNAME?.trim() || "admin";
|
||||
const password = process.env.ADMIN_PASSWORD;
|
||||
if (!password) {
|
||||
throw new Error("ADMIN_PASSWORD is not set — copy .env.example to .env first.");
|
||||
}
|
||||
const passwordHash = await hashPassword(password);
|
||||
await db
|
||||
.insert(users)
|
||||
.values({ username, passwordHash })
|
||||
.onConflictDoUpdate({ target: users.username, set: { passwordHash } });
|
||||
log(`admin user “${username}” ready`);
|
||||
|
||||
// --- Site settings (only created, never overwritten) -------------------
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({
|
||||
id: 1,
|
||||
siteTitle: "Yap Blog",
|
||||
headerText: "Field notes from a solarized terminal.",
|
||||
footerText: "© 2026 Yap Blog · Set in base03 · Powered by Next.js, Postgres & Drizzle",
|
||||
postsPerPage: 5,
|
||||
excerptWords: 40,
|
||||
homeMode: "posts",
|
||||
theme: "solarized-dark",
|
||||
font: "geist",
|
||||
})
|
||||
.onConflictDoNothing({ target: settings.id });
|
||||
|
||||
// --- Content (skipped when posts already exist) ------------------------
|
||||
const [{ value: postCount }] = await db.select({ value: count() }).from(posts);
|
||||
if (postCount > 0) {
|
||||
log("content already present — skipping demo content");
|
||||
return;
|
||||
}
|
||||
|
||||
const tagRows = await db
|
||||
.insert(tags)
|
||||
.values([
|
||||
{ name: "Design", slug: "design" },
|
||||
{ name: "Next.js", slug: "nextjs" },
|
||||
{ name: "PostgreSQL", slug: "postgresql" },
|
||||
{ name: "TypeScript", slug: "typescript" },
|
||||
{ name: "Writing", slug: "writing" },
|
||||
{ name: "Tooling", slug: "tooling" },
|
||||
{ name: "Accessibility", slug: "accessibility" },
|
||||
{ name: "Performance", slug: "performance" },
|
||||
// Used only by a draft post — must never appear in the public sidebar.
|
||||
{ name: "Secrets", slug: "secrets" },
|
||||
])
|
||||
.returning();
|
||||
const tagId = new Map(tagRows.map((t) => [t.slug, t.id]));
|
||||
|
||||
const postRows = await db
|
||||
.insert(posts)
|
||||
.values([
|
||||
{
|
||||
title: "Hello, Nightfall",
|
||||
slug: "hello-nightfall",
|
||||
body: renderMarkdown(POST_BODIES.hello),
|
||||
authorName: "Matt",
|
||||
status: "published",
|
||||
publishedAt: daysAgo(42),
|
||||
featuredImageUrl: "https://picsum.photos/seed/nightfall/1200/600",
|
||||
featuredImageAlt: "Abstract dark landscape at dusk",
|
||||
},
|
||||
{
|
||||
title: "Why Solarized Dark refuses to die",
|
||||
slug: "why-solarized-dark-refuses-to-die",
|
||||
body: renderMarkdown(POST_BODIES.solarized),
|
||||
authorName: "Matt",
|
||||
status: "published",
|
||||
publishedAt: daysAgo(35),
|
||||
featuredImageUrl: "https://picsum.photos/seed/solarized/1200/600",
|
||||
featuredImageAlt: "Deep blue-green gradient reminiscent of the Solarized base tones",
|
||||
},
|
||||
{
|
||||
title: "The Markdown kitchen sink",
|
||||
slug: "markdown-kitchen-sink",
|
||||
body: renderMarkdown(POST_BODIES.markdown),
|
||||
authorName: "Matt",
|
||||
status: "published",
|
||||
publishedAt: daysAgo(28),
|
||||
},
|
||||
{
|
||||
title: "Drizzle ORM in anger: schema, migrations, and calm",
|
||||
slug: "drizzle-orm-in-anger",
|
||||
body: renderMarkdown(POST_BODIES.drizzle),
|
||||
authorName: "Matt",
|
||||
status: "published",
|
||||
publishedAt: daysAgo(21),
|
||||
featuredImageUrl: "https://picsum.photos/seed/drizzle/1200/600",
|
||||
featuredImageAlt: "Rain drizzling on a window at night",
|
||||
},
|
||||
{
|
||||
title: "Keyboard-first blogging",
|
||||
slug: "keyboard-first-blogging",
|
||||
body: renderMarkdown(POST_BODIES.keyboard),
|
||||
authorName: "Matt",
|
||||
status: "published",
|
||||
publishedAt: daysAgo(14),
|
||||
},
|
||||
{
|
||||
title: "Postgres is enough",
|
||||
slug: "postgres-is-enough",
|
||||
body: renderMarkdown(POST_BODIES.postgres),
|
||||
authorName: "Matt",
|
||||
status: "published",
|
||||
publishedAt: daysAgo(7),
|
||||
// Deliberately broken URL: demonstrates the image-error fallback.
|
||||
featuredImageUrl: "https://broken.invalid/elephant.jpg",
|
||||
featuredImageAlt: "A sturdy elephant carrying a database",
|
||||
},
|
||||
{
|
||||
title: "On actually writing",
|
||||
slug: "on-actually-writing",
|
||||
body: renderMarkdown(POST_BODIES.writing),
|
||||
authorName: "Matt",
|
||||
status: "published",
|
||||
publishedAt: daysAgo(2),
|
||||
},
|
||||
{
|
||||
title: "Draft: ideas backlog",
|
||||
slug: "draft-ideas-backlog",
|
||||
body: renderMarkdown(POST_BODIES.draftIdeas),
|
||||
authorName: "Matt",
|
||||
status: "draft",
|
||||
},
|
||||
{
|
||||
title: "Secret draft (should never be public)",
|
||||
slug: "secret-draft",
|
||||
body: renderMarkdown(POST_BODIES.draftSecret),
|
||||
authorName: "Matt",
|
||||
status: "draft",
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
const postId = new Map(postRows.map((p) => [p.slug, p.id]));
|
||||
|
||||
const link = (postSlug: string, ...tagSlugs: string[]) =>
|
||||
tagSlugs.map((slug) => ({
|
||||
postId: postId.get(postSlug)!,
|
||||
tagId: tagId.get(slug)!,
|
||||
}));
|
||||
|
||||
await db.insert(postTags).values([
|
||||
...link("hello-nightfall", "writing", "design"),
|
||||
...link("why-solarized-dark-refuses-to-die", "design"),
|
||||
...link("markdown-kitchen-sink", "writing", "design"),
|
||||
...link("drizzle-orm-in-anger", "typescript", "postgresql", "nextjs"),
|
||||
...link("keyboard-first-blogging", "design", "nextjs"),
|
||||
...link("postgres-is-enough", "postgresql"),
|
||||
...link("on-actually-writing", "writing"),
|
||||
...link("draft-ideas-backlog", "secrets"),
|
||||
...link("secret-draft", "secrets"),
|
||||
]);
|
||||
|
||||
// Bulk demo posts, older than the handwritten ones so those stay on
|
||||
// page 1. Published every ~2 weeks going back roughly two years.
|
||||
const bulkRows = await db
|
||||
.insert(posts)
|
||||
.values(
|
||||
BULK_POSTS.map((spec, i) => {
|
||||
const slug = slugify(spec.title);
|
||||
return {
|
||||
title: spec.title,
|
||||
slug,
|
||||
body: renderMarkdown(buildBulkBody(spec.topic, i)),
|
||||
authorName: "Matt",
|
||||
status: "published" as const,
|
||||
publishedAt: daysAgo(50 + i * 15),
|
||||
...(i % 3 === 0
|
||||
? {
|
||||
featuredImageUrl: `https://picsum.photos/seed/${slug}/1200/600`,
|
||||
featuredImageAlt: `Abstract illustration for “${spec.title}”`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning({ id: posts.id, slug: posts.slug });
|
||||
|
||||
const bulkSlugToId = new Map(bulkRows.map((r) => [r.slug, r.id]));
|
||||
await db.insert(postTags).values(
|
||||
BULK_POSTS.flatMap((spec) => {
|
||||
const postId = bulkSlugToId.get(slugify(spec.title))!;
|
||||
return BULK_TOPICS[spec.topic].tagSlugs.map((slug) => ({
|
||||
postId,
|
||||
tagId: tagId.get(slug)!,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
const pageRows = await db
|
||||
.insert(pages)
|
||||
.values([
|
||||
{ title: "About", slug: "about", body: renderMarkdown(PAGE_BODIES.about), status: "published" },
|
||||
{ title: "Colophon", slug: "colophon", body: renderMarkdown(PAGE_BODIES.colophon), status: "published" },
|
||||
{ title: "Roadmap", slug: "roadmap", body: renderMarkdown(PAGE_BODIES.roadmap), status: "draft" },
|
||||
])
|
||||
.returning();
|
||||
const aboutPage = pageRows.find((p) => p.slug === "about")!;
|
||||
|
||||
await db.insert(navItems).values([
|
||||
{ label: "All posts", url: "/posts", pageId: null, sortOrder: 0 },
|
||||
{ label: "About", url: null, pageId: aboutPage.id, sortOrder: 1 },
|
||||
{ label: "Solarized", url: "https://ethanschoonover.com/solarized/", pageId: null, sortOrder: 2 },
|
||||
]);
|
||||
|
||||
log(
|
||||
`seeded ${postRows.filter((p) => p.status === "published").length + bulkRows.length} published posts, ` +
|
||||
`${postRows.filter((p) => p.status === "draft").length} drafts, ` +
|
||||
`${tagRows.length} tags, ${pageRows.length} pages`,
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun =
|
||||
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (isDirectRun) {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set — copy .env.example to .env first.");
|
||||
process.exit(1);
|
||||
}
|
||||
seed(url, (msg) => console.log(`[seed] ${msg}`))
|
||||
.then(() => console.log("[seed] done"))
|
||||
.catch((error) => {
|
||||
console.error("[seed] failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
71
src/actions/auth.ts
Normal file
71
src/actions/auth.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import {
|
||||
clearSessionCookie,
|
||||
readSessionCookie,
|
||||
setSessionCookie,
|
||||
} from "@/lib/auth/cookies";
|
||||
import { hashPassword, verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession, deleteExpiredSessions, deleteSession } from "@/lib/auth/session";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import { loginFormSchema } from "@/lib/validation";
|
||||
|
||||
const GENERIC_LOGIN_ERROR = "Invalid username or password.";
|
||||
|
||||
// Verified against when the username doesn't exist, so both failure paths
|
||||
// cost one scrypt derivation (no username-probing timing signal).
|
||||
let dummyHashPromise: Promise<string> | null = null;
|
||||
function dummyHash(): Promise<string> {
|
||||
dummyHashPromise ??= hashPassword("dummy-password-for-timing");
|
||||
return dummyHashPromise;
|
||||
}
|
||||
|
||||
export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
|
||||
const parsed = loginFormSchema.safeParse({
|
||||
username: formData.get("username"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, parsed.data.username))
|
||||
.limit(1);
|
||||
|
||||
const storedHash = user?.passwordHash ?? (await dummyHash());
|
||||
const passwordOk = await verifyPassword(storedHash, parsed.data.password);
|
||||
ok = passwordOk && user !== undefined;
|
||||
|
||||
if (ok && user) {
|
||||
await deleteExpiredSessions();
|
||||
const { token, expiresAt } = await createSession(user.id);
|
||||
await setSessionCookie(token, expiresAt);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("loginAction failed", error);
|
||||
return { formError: "Could not sign in right now. Please try again." };
|
||||
}
|
||||
|
||||
if (!ok) return { formError: GENERIC_LOGIN_ERROR };
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
try {
|
||||
const token = await readSessionCookie();
|
||||
if (token) await deleteSession(token);
|
||||
} catch (error) {
|
||||
// Losing the DB row is not fatal — the cookie is cleared regardless.
|
||||
console.error("logoutAction failed", error);
|
||||
}
|
||||
await clearSessionCookie();
|
||||
redirect("/admin/login");
|
||||
}
|
||||
80
src/actions/pages.ts
Normal file
80
src/actions/pages.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import { sanitizeHtml } from "@/lib/html";
|
||||
import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
|
||||
import {
|
||||
createPage,
|
||||
deletePage,
|
||||
setPageStatus,
|
||||
updatePage,
|
||||
} from "@/lib/services/pages";
|
||||
import { pageFormSchema } from "@/lib/validation";
|
||||
|
||||
async function savePage(id: number | null, formData: FormData): Promise<FormState> {
|
||||
await requireAdmin();
|
||||
const parsed = pageFormSchema.safeParse({
|
||||
title: formData.get("title"),
|
||||
slug: formData.get("slug"),
|
||||
body: formData.get("body"),
|
||||
status: formData.get("status"),
|
||||
});
|
||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||
|
||||
// Editor HTML is sanitized at the trust boundary; render sanitizes again.
|
||||
const input = { ...parsed.data, body: sanitizeHtml(parsed.data.body) };
|
||||
|
||||
let pageId: number;
|
||||
try {
|
||||
if (id === null) {
|
||||
const page = await createPage(input);
|
||||
pageId = page.id;
|
||||
} else {
|
||||
const page = await updatePage(id, input);
|
||||
if (!page) return { formError: "This page no longer exists." };
|
||||
pageId = page.id;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SlugConflictError) {
|
||||
return { fieldErrors: { slug: [error.message] } };
|
||||
}
|
||||
if (isUniqueViolation(error)) {
|
||||
return { fieldErrors: { slug: ["That slug was just taken. Choose another."] } };
|
||||
}
|
||||
console.error("savePage failed", error);
|
||||
return { formError: "Something went wrong while saving. Please try again." };
|
||||
}
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
redirect(`/admin/pages/${pageId}/edit?saved=1`);
|
||||
}
|
||||
|
||||
export async function createPageAction(_prev: FormState, formData: FormData) {
|
||||
return savePage(null, formData);
|
||||
}
|
||||
|
||||
export async function updatePageAction(id: number, _prev: FormState, formData: FormData) {
|
||||
const pageId = z.number().int().positive().parse(id);
|
||||
return savePage(pageId, formData);
|
||||
}
|
||||
|
||||
export async function setPageStatusAction(id: number, status: "draft" | "published") {
|
||||
await requireAdmin();
|
||||
const pageId = z.number().int().positive().parse(id);
|
||||
const nextStatus = z.enum(["draft", "published"]).parse(status);
|
||||
await setPageStatus(pageId, nextStatus);
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function deletePageAction(id: number) {
|
||||
await requireAdmin();
|
||||
const pageId = z.number().int().positive().parse(id);
|
||||
await deletePage(pageId);
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/admin/pages?deleted=1");
|
||||
}
|
||||
102
src/actions/posts.ts
Normal file
102
src/actions/posts.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import { sanitizeHtml } from "@/lib/html";
|
||||
import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
|
||||
import {
|
||||
createPost,
|
||||
deletePost,
|
||||
type PostInput,
|
||||
setPostStatus,
|
||||
updatePost,
|
||||
} from "@/lib/services/posts";
|
||||
import { postFormSchema } from "@/lib/validation";
|
||||
|
||||
function readPostForm(formData: FormData) {
|
||||
return postFormSchema.safeParse({
|
||||
title: formData.get("title"),
|
||||
slug: formData.get("slug"),
|
||||
authorName: formData.get("authorName"),
|
||||
body: formData.get("body"),
|
||||
featuredImageUrl: formData.get("featuredImageUrl"),
|
||||
featuredImageAlt: formData.get("featuredImageAlt"),
|
||||
status: formData.get("status"),
|
||||
tagIds: formData.getAll("tagIds"),
|
||||
newTags: formData.get("newTags"),
|
||||
});
|
||||
}
|
||||
|
||||
function toPostInput(data: z.infer<typeof postFormSchema>): PostInput {
|
||||
return {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
// Editor HTML is sanitized at the trust boundary; render sanitizes again.
|
||||
body: sanitizeHtml(data.body),
|
||||
authorName: data.authorName,
|
||||
featuredImageUrl: data.featuredImageUrl === "" ? null : data.featuredImageUrl,
|
||||
featuredImageAlt: data.featuredImageAlt === "" ? null : data.featuredImageAlt,
|
||||
status: data.status,
|
||||
tagIds: data.tagIds,
|
||||
newTagNames: data.newTags.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
async function savePost(id: number | null, formData: FormData): Promise<FormState> {
|
||||
await requireAdmin();
|
||||
const parsed = readPostForm(formData);
|
||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||
|
||||
let postId: number;
|
||||
try {
|
||||
if (id === null) {
|
||||
const post = await createPost(toPostInput(parsed.data));
|
||||
postId = post.id;
|
||||
} else {
|
||||
const post = await updatePost(id, toPostInput(parsed.data));
|
||||
if (!post) return { formError: "This post no longer exists." };
|
||||
postId = post.id;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SlugConflictError) {
|
||||
return { fieldErrors: { slug: [error.message] } };
|
||||
}
|
||||
if (isUniqueViolation(error)) {
|
||||
return { fieldErrors: { slug: ["That slug was just taken. Choose another."] } };
|
||||
}
|
||||
console.error("savePost failed", error);
|
||||
return { formError: "Something went wrong while saving. Please try again." };
|
||||
}
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
redirect(`/admin/posts/${postId}/edit?saved=1`);
|
||||
}
|
||||
|
||||
export async function createPostAction(_prev: FormState, formData: FormData) {
|
||||
return savePost(null, formData);
|
||||
}
|
||||
|
||||
export async function updatePostAction(id: number, _prev: FormState, formData: FormData) {
|
||||
const postId = z.number().int().positive().parse(id);
|
||||
return savePost(postId, formData);
|
||||
}
|
||||
|
||||
export async function setPostStatusAction(id: number, status: "draft" | "published") {
|
||||
await requireAdmin();
|
||||
const postId = z.number().int().positive().parse(id);
|
||||
const nextStatus = z.enum(["draft", "published"]).parse(status);
|
||||
await setPostStatus(postId, nextStatus);
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function deletePostAction(id: number) {
|
||||
await requireAdmin();
|
||||
const postId = z.number().int().positive().parse(id);
|
||||
await deletePost(postId);
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/admin/posts?deleted=1");
|
||||
}
|
||||
104
src/actions/settings.ts
Normal file
104
src/actions/settings.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use server";
|
||||
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { pages, tags } from "@/db/schema";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import type { FormState } from "@/lib/forms";
|
||||
import { zodErrorToFormState } from "@/lib/forms";
|
||||
import { type NavItemInput, saveSettings } from "@/lib/services/settings";
|
||||
import { parseNavItemsJson, settingsFormSchema } from "@/lib/validation";
|
||||
|
||||
export async function updateSettingsAction(
|
||||
_prev: FormState,
|
||||
formData: FormData,
|
||||
): Promise<FormState> {
|
||||
await requireAdmin();
|
||||
|
||||
const parsed = settingsFormSchema.safeParse({
|
||||
siteTitle: formData.get("siteTitle"),
|
||||
headerText: formData.get("headerText"),
|
||||
footerText: formData.get("footerText"),
|
||||
postsPerPage: formData.get("postsPerPage"),
|
||||
excerptWords: formData.get("excerptWords"),
|
||||
homeMode: formData.get("homeMode"),
|
||||
homeTagId: formData.get("homeTagId"),
|
||||
homePageId: formData.get("homePageId"),
|
||||
theme: formData.get("theme"),
|
||||
font: formData.get("font"),
|
||||
navItemsJson: formData.get("navItemsJson"),
|
||||
});
|
||||
if (!parsed.success) return zodErrorToFormState(parsed.error);
|
||||
const data = parsed.data;
|
||||
|
||||
const navResult = parseNavItemsJson(data.navItemsJson);
|
||||
if ("error" in navResult) return { formError: navResult.error };
|
||||
const nav: NavItemInput[] = navResult.items.map((item) => ({
|
||||
label: item.label,
|
||||
url: item.url === "" ? null : item.url,
|
||||
pageId: item.pageId,
|
||||
}));
|
||||
|
||||
try {
|
||||
// Verify referenced rows still exist (they may have been deleted in
|
||||
// another tab); dangling ids become validation errors, not FK crashes.
|
||||
if (data.homeMode === "tag") {
|
||||
if (!data.homeTagId) {
|
||||
return { fieldErrors: { homeTagId: ["Choose a tag for the home page."] } };
|
||||
}
|
||||
const found = await db
|
||||
.select({ id: tags.id })
|
||||
.from(tags)
|
||||
.where(inArray(tags.id, [data.homeTagId]));
|
||||
if (found.length === 0) {
|
||||
return { fieldErrors: { homeTagId: ["That tag no longer exists."] } };
|
||||
}
|
||||
}
|
||||
if (data.homeMode === "page") {
|
||||
if (!data.homePageId) {
|
||||
return { fieldErrors: { homePageId: ["Choose a page for the home page."] } };
|
||||
}
|
||||
const found = await db
|
||||
.select({ id: pages.id })
|
||||
.from(pages)
|
||||
.where(inArray(pages.id, [data.homePageId]));
|
||||
if (found.length === 0) {
|
||||
return { fieldErrors: { homePageId: ["That page no longer exists."] } };
|
||||
}
|
||||
}
|
||||
|
||||
const navPageIds = nav.flatMap((i) => (i.pageId !== null ? [i.pageId] : []));
|
||||
if (navPageIds.length > 0) {
|
||||
const found = await db
|
||||
.select({ id: pages.id })
|
||||
.from(pages)
|
||||
.where(inArray(pages.id, navPageIds));
|
||||
if (found.length !== new Set(navPageIds).size) {
|
||||
return { formError: "A navigation item points at a page that no longer exists." };
|
||||
}
|
||||
}
|
||||
|
||||
await saveSettings(
|
||||
{
|
||||
siteTitle: data.siteTitle,
|
||||
headerText: data.headerText,
|
||||
footerText: data.footerText,
|
||||
postsPerPage: data.postsPerPage,
|
||||
excerptWords: data.excerptWords,
|
||||
homeMode: data.homeMode,
|
||||
homeTagId: data.homeMode === "tag" ? (data.homeTagId ?? null) : null,
|
||||
homePageId: data.homeMode === "page" ? (data.homePageId ?? null) : null,
|
||||
theme: data.theme,
|
||||
font: data.font,
|
||||
},
|
||||
nav,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("updateSettingsAction failed", error);
|
||||
return { formError: "Something went wrong while saving settings. Please try again." };
|
||||
}
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
return { status: "success" };
|
||||
}
|
||||
21
src/app/(public)/(home)/loading.tsx
Normal file
21
src/app/(public)/(home)/loading.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export default function PublicLoading() {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="grid gap-6">
|
||||
<span className="sr-only">Loading…</span>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className="animate-pulse rounded-lg border border-edge bg-surface p-6"
|
||||
>
|
||||
<div className="h-5 w-2/3 rounded bg-background" />
|
||||
<div className="mt-3 h-3 w-1/3 rounded bg-background" />
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="h-3 w-full rounded bg-background" />
|
||||
<div className="h-3 w-5/6 rounded bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/app/(public)/(home)/page.tsx
Normal file
40
src/app/(public)/(home)/page.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { PageArticle } from "@/components/public/PageArticle";
|
||||
import { PostListSection } from "@/components/public/PostListSection";
|
||||
import { parsePage } from "@/lib/pagination";
|
||||
import { resolveHomeContent } from "@/lib/services/home";
|
||||
import { getSettings } from "@/lib/services/settings";
|
||||
|
||||
/**
|
||||
* Configured home page: the full post list, a tag's post list, or a
|
||||
* static page — decided by site settings, with a safe fallback to the
|
||||
* post list when the configured tag/page has gone away.
|
||||
*/
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const [sp, settings] = await Promise.all([searchParams, getSettings()]);
|
||||
const home = await resolveHomeContent(settings);
|
||||
|
||||
if (home.kind === "page") {
|
||||
return <PageArticle page={home.page} />;
|
||||
}
|
||||
|
||||
const heading = home.kind === "tag" ? `Posts tagged “${home.tag.name}”` : "Latest posts";
|
||||
return (
|
||||
<section aria-labelledby="home-heading">
|
||||
<h1 id="home-heading" className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">
|
||||
{heading}
|
||||
</h1>
|
||||
<PostListSection
|
||||
page={parsePage(sp.page)}
|
||||
perPage={settings.postsPerPage}
|
||||
excerptWords={settings.excerptWords}
|
||||
tagId={home.kind === "tag" ? home.tag.id : undefined}
|
||||
basePath="/"
|
||||
emptyMessage="No posts have been published yet. Check back soon!"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
9
src/app/(public)/[...rest]/page.tsx
Normal file
9
src/app/(public)/[...rest]/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { notFound } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Catch-all for unknown public URLs so they render the themed 404 inside
|
||||
* the public layout instead of the bare root not-found page.
|
||||
*/
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
31
src/app/(public)/layout.tsx
Normal file
31
src/app/(public)/layout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { SiteFooter } from "@/components/public/SiteFooter";
|
||||
import { SiteHeader } from "@/components/public/SiteHeader";
|
||||
import { SiteSidebar } from "@/components/public/SiteSidebar";
|
||||
import { getSettings, listPublicNav } from "@/lib/services/settings";
|
||||
import { listPublicTags } from "@/lib/services/tags";
|
||||
|
||||
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const [settings, nav, tags] = await Promise.all([
|
||||
getSettings(),
|
||||
listPublicNav(),
|
||||
listPublicTags(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteHeader
|
||||
siteTitle={settings.siteTitle}
|
||||
headerText={settings.headerText}
|
||||
nav={nav}
|
||||
tags={tags}
|
||||
/>
|
||||
<div className="container-site flex-1 py-8 sm:py-10 lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-start lg:gap-10">
|
||||
<main id="main" className="min-w-0">
|
||||
{children}
|
||||
</main>
|
||||
<SiteSidebar tags={tags} />
|
||||
</div>
|
||||
<SiteFooter text={settings.footerText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
src/app/(public)/not-found.tsx
Normal file
20
src/app/(public)/not-found.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import Link from "next/link";
|
||||
|
||||
/** 404 shown inside the public chrome (header, sidebar, footer stay put). */
|
||||
export default function PublicNotFound() {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="font-mono text-sm text-ink-muted">404</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-ink-strong">Not found</h1>
|
||||
<p className="mt-3 text-sm text-ink-muted">
|
||||
That post, tag, or page does not exist — it may have been unpublished or removed.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex items-center rounded-md bg-link px-4 py-2 text-sm font-medium text-ink-inverse transition-colors hover:bg-link-hover"
|
||||
>
|
||||
Back to the front page
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/(public)/pages/[slug]/page.tsx
Normal file
21
src/app/(public)/pages/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PageArticle } from "@/components/public/PageArticle";
|
||||
import { getPublishedPageBySlug } from "@/lib/services/pages";
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const page = await getPublishedPageBySlug(slug);
|
||||
if (!page) return {};
|
||||
return { title: page.title };
|
||||
}
|
||||
|
||||
export default async function StaticPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
// Draft pages are filtered inside the query — they 404 like unknown slugs.
|
||||
const page = await getPublishedPageBySlug(slug);
|
||||
if (!page) notFound();
|
||||
return <PageArticle page={page} />;
|
||||
}
|
||||
21
src/app/(public)/posts/(list)/loading.tsx
Normal file
21
src/app/(public)/posts/(list)/loading.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export default function PublicLoading() {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="grid gap-6">
|
||||
<span className="sr-only">Loading…</span>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className="animate-pulse rounded-lg border border-edge bg-surface p-6"
|
||||
>
|
||||
<div className="h-5 w-2/3 rounded bg-background" />
|
||||
<div className="mt-3 h-3 w-1/3 rounded bg-background" />
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="h-3 w-full rounded bg-background" />
|
||||
<div className="h-3 w-5/6 rounded bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/app/(public)/posts/(list)/page.tsx
Normal file
29
src/app/(public)/posts/(list)/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Metadata } from "next";
|
||||
import { PostListSection } from "@/components/public/PostListSection";
|
||||
import { parsePage } from "@/lib/pagination";
|
||||
import { getSettings } from "@/lib/services/settings";
|
||||
|
||||
export const metadata: Metadata = { title: "All posts" };
|
||||
|
||||
export default async function PostsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const [sp, settings] = await Promise.all([searchParams, getSettings()]);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="posts-heading">
|
||||
<h1 id="posts-heading" className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">
|
||||
All posts
|
||||
</h1>
|
||||
<PostListSection
|
||||
page={parsePage(sp.page)}
|
||||
perPage={settings.postsPerPage}
|
||||
excerptWords={settings.excerptWords}
|
||||
basePath="/posts"
|
||||
emptyMessage="No posts have been published yet. Check back soon!"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
src/app/(public)/posts/[slug]/page.tsx
Normal file
22
src/app/(public)/posts/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PostArticle } from "@/components/public/PostArticle";
|
||||
import { generateExcerpt } from "@/lib/excerpt";
|
||||
import { getPublishedPostBySlug } from "@/lib/services/posts";
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPublishedPostBySlug(slug);
|
||||
if (!post) return {};
|
||||
return { title: post.title, description: generateExcerpt(post.body, 30) || undefined };
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
// Draft posts are filtered inside the query — they 404 like unknown slugs.
|
||||
const post = await getPublishedPostBySlug(slug);
|
||||
if (!post) notFound();
|
||||
return <PostArticle post={post} />;
|
||||
}
|
||||
45
src/app/(public)/tags/[slug]/page.tsx
Normal file
45
src/app/(public)/tags/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { PostListSection } from "@/components/public/PostListSection";
|
||||
import { parsePage } from "@/lib/pagination";
|
||||
import { getSettings } from "@/lib/services/settings";
|
||||
import { getPublicTagBySlug } from "@/lib/services/tags";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const tag = await getPublicTagBySlug(slug);
|
||||
if (!tag) return {};
|
||||
return { title: `Posts tagged “${tag.name}”` };
|
||||
}
|
||||
|
||||
export default async function TagPage({ params, searchParams }: Props) {
|
||||
const [{ slug }, sp, settings] = await Promise.all([params, searchParams, getSettings()]);
|
||||
|
||||
// Unknown tags and tags used only by drafts both resolve to null → 404.
|
||||
const tag = await getPublicTagBySlug(slug);
|
||||
if (!tag) notFound();
|
||||
|
||||
return (
|
||||
<section aria-labelledby="tag-heading">
|
||||
<h1 id="tag-heading" className="text-2xl font-bold tracking-tight text-ink-bright">
|
||||
Posts tagged “{tag.name}”
|
||||
</h1>
|
||||
<p className="mb-6 mt-1 text-sm text-ink-muted">
|
||||
{tag.postCount} {tag.postCount === 1 ? "post" : "posts"}
|
||||
</p>
|
||||
<PostListSection
|
||||
page={parsePage(sp.page)}
|
||||
perPage={settings.postsPerPage}
|
||||
excerptWords={settings.excerptWords}
|
||||
tagId={tag.id}
|
||||
basePath={`/tags/${tag.slug}`}
|
||||
emptyMessage="No published posts carry this tag yet."
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
63
src/app/admin/(panel)/layout.tsx
Normal file
63
src/app/admin/(panel)/layout.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import Link from "next/link";
|
||||
import { logoutAction } from "@/actions/auth";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { getSettings } from "@/lib/services/settings";
|
||||
|
||||
const navLinkClasses =
|
||||
"rounded-md px-2.5 py-1.5 text-sm font-medium text-ink transition-colors hover:bg-background hover:text-ink-strong";
|
||||
|
||||
/**
|
||||
* Every route in this group is server-guarded: the layout redirects
|
||||
* anonymous visitors, each page calls requireAdmin() again (defense in
|
||||
* depth), and every mutating server action re-checks on its own.
|
||||
*/
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireAdmin();
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-edge bg-surface">
|
||||
<div className="container-site flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="font-semibold text-ink-bright transition-colors hover:text-link"
|
||||
>
|
||||
{settings.siteTitle}
|
||||
<span className="font-normal text-ink-muted"> · Admin</span>
|
||||
</Link>
|
||||
<nav aria-label="Admin sections">
|
||||
<ul className="flex items-center gap-1">
|
||||
<li><Link href="/admin" className={navLinkClasses}>Dashboard</Link></li>
|
||||
<li><Link href="/admin/posts" className={navLinkClasses}>Posts</Link></li>
|
||||
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
|
||||
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link href="/" className="text-ink-muted transition-colors hover:text-link">
|
||||
View site
|
||||
</Link>
|
||||
<span aria-hidden="true" className="text-edge-strong">|</span>
|
||||
<span className="text-ink-muted">
|
||||
Signed in as <span className="text-ink-strong">{user.username}</span>
|
||||
</span>
|
||||
<form action={logoutAction}>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-edge px-3 py-1.5 font-medium text-ink transition-colors hover:border-edge-strong hover:text-ink-strong"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main" className="container-site flex-1 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
src/app/admin/(panel)/loading.tsx
Normal file
7
src/app/admin/(panel)/loading.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export default function AdminLoading() {
|
||||
return (
|
||||
<p role="status" className="py-16 text-center text-sm text-ink-muted">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
19
src/app/admin/(panel)/not-found.tsx
Normal file
19
src/app/admin/(panel)/not-found.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export default function AdminNotFound() {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="font-mono text-sm text-ink-muted">404</p>
|
||||
<h1 className="mt-2 text-xl font-semibold text-ink-strong">Not found</h1>
|
||||
<p className="mt-3 text-sm text-ink-muted">
|
||||
That post or page does not exist — it may have been deleted.
|
||||
</p>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="mt-6 inline-flex items-center rounded-md bg-link px-4 py-2 text-sm font-medium text-ink-inverse transition-colors hover:bg-link-hover"
|
||||
>
|
||||
Back to the dashboard
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/app/admin/(panel)/page.tsx
Normal file
84
src/app/admin/(panel)/page.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { LinkButton } from "@/components/ui";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { listAllPages } from "@/lib/services/pages";
|
||||
import { countPostsByStatus, listAllPosts } from "@/lib/services/posts";
|
||||
import { listAllTags } from "@/lib/services/tags";
|
||||
|
||||
export const metadata: Metadata = { title: "Dashboard" };
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
await requireAdmin();
|
||||
const [postCounts, allPosts, allPages, allTags] = await Promise.all([
|
||||
countPostsByStatus(),
|
||||
listAllPosts(),
|
||||
listAllPages(),
|
||||
listAllTags(),
|
||||
]);
|
||||
const recentPosts = allPosts.slice(0, 5);
|
||||
|
||||
const stats = [
|
||||
{ label: "Published posts", value: postCounts.published },
|
||||
{ label: "Draft posts", value: postCounts.draft },
|
||||
{ label: "Pages", value: allPages.length },
|
||||
{ label: "Tags", value: allTags.length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Dashboard</h1>
|
||||
<div className="flex gap-2">
|
||||
<LinkButton href="/admin/posts/new">New post</LinkButton>
|
||||
<LinkButton href="/admin/pages/new" variant="secondary">New page</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border border-edge bg-surface p-5">
|
||||
<dt className="text-sm text-ink-muted">{stat.label}</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-ink-bright">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<section aria-labelledby="recent-heading" className="mt-10">
|
||||
<h2 id="recent-heading" className="text-lg font-semibold text-ink-strong">
|
||||
Recently updated posts
|
||||
</h2>
|
||||
{recentPosts.length === 0 ? (
|
||||
<p className="mt-4 rounded-lg border border-dashed border-edge-strong px-4 py-8 text-center text-sm text-ink-muted">
|
||||
No posts yet —{" "}
|
||||
<Link href="/admin/posts/new" className="text-link underline underline-offset-4">
|
||||
write the first one
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-4 divide-y divide-edge rounded-lg border border-edge bg-surface">
|
||||
{recentPosts.map((post) => (
|
||||
<li key={post.id} className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/admin/posts/${post.id}/edit`}
|
||||
className="font-medium text-ink-strong transition-colors hover:text-link"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
<p className="mt-0.5 text-xs text-ink-muted">
|
||||
Updated {formatDate(post.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={post.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/app/admin/(panel)/pages/[id]/edit/page.tsx
Normal file
56
src/app/admin/(panel)/pages/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { updatePageAction } from "@/actions/pages";
|
||||
import { Flash } from "@/components/admin/Flash";
|
||||
import { PageForm } from "@/components/admin/PageForm";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { parseIdParam } from "@/lib/params";
|
||||
import { getPageById } from "@/lib/services/pages";
|
||||
|
||||
export const metadata: Metadata = { title: "Edit page" };
|
||||
|
||||
export default async function EditPagePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
await requireAdmin();
|
||||
const [{ id: rawId }, sp] = await Promise.all([params, searchParams]);
|
||||
|
||||
const id = parseIdParam(rawId);
|
||||
if (id === null) notFound();
|
||||
|
||||
const page = await getPageById(id);
|
||||
if (!page) notFound();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Edit page</h1>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link
|
||||
href={`/admin/pages/${page.id}/preview`}
|
||||
className="text-ink-muted transition-colors hover:text-link"
|
||||
>
|
||||
Preview
|
||||
</Link>
|
||||
{page.status === "published" && (
|
||||
<Link
|
||||
href={`/pages/${page.slug}`}
|
||||
className="text-ink-muted transition-colors hover:text-link"
|
||||
>
|
||||
View on site
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sp.saved === "1" && <Flash>Saved.</Flash>}
|
||||
|
||||
<PageForm page={page} action={updatePageAction.bind(null, page.id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/app/admin/(panel)/pages/[id]/preview/page.tsx
Normal file
43
src/app/admin/(panel)/pages/[id]/preview/page.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { PageArticle } from "@/components/public/PageArticle";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { parseIdParam } from "@/lib/params";
|
||||
import { getPageById } from "@/lib/services/pages";
|
||||
|
||||
export const metadata: Metadata = { title: "Preview page" };
|
||||
|
||||
/** Renders the static page exactly as the public site would — drafts included. */
|
||||
export default async function PagePreviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
await requireAdmin();
|
||||
const { id: rawId } = await params;
|
||||
const id = parseIdParam(rawId);
|
||||
if (id === null) notFound();
|
||||
|
||||
const page = await getPageById(id);
|
||||
if (!page) notFound();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
|
||||
<p className="flex items-center gap-2 text-warning">
|
||||
<span className="font-medium">Preview</span>
|
||||
<StatusBadge status={page.status} />
|
||||
</p>
|
||||
<Link
|
||||
href={`/admin/pages/${page.id}/edit`}
|
||||
className="font-medium text-warning underline underline-offset-4"
|
||||
>
|
||||
Back to editor
|
||||
</Link>
|
||||
</div>
|
||||
<PageArticle page={page} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/app/admin/(panel)/pages/new/page.tsx
Normal file
16
src/app/admin/(panel)/pages/new/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Metadata } from "next";
|
||||
import { createPageAction } from "@/actions/pages";
|
||||
import { PageForm } from "@/components/admin/PageForm";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
|
||||
export const metadata: Metadata = { title: "New page" };
|
||||
|
||||
export default async function NewPagePage() {
|
||||
await requireAdmin();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">New page</h1>
|
||||
<PageForm action={createPageAction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/app/admin/(panel)/pages/page.tsx
Normal file
104
src/app/admin/(panel)/pages/page.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { deletePageAction, setPageStatusAction } from "@/actions/pages";
|
||||
import { ConfirmButton } from "@/components/admin/ConfirmButton";
|
||||
import { Flash } from "@/components/admin/Flash";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { LinkButton } from "@/components/ui";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { listAllPages } from "@/lib/services/pages";
|
||||
|
||||
export const metadata: Metadata = { title: "Pages" };
|
||||
|
||||
const actionButtonClasses =
|
||||
"rounded-md px-2 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-background hover:text-ink-strong";
|
||||
|
||||
export default async function AdminPagesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
await requireAdmin();
|
||||
const [sp, pages] = await Promise.all([searchParams, listAllPages()]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Pages</h1>
|
||||
<LinkButton href="/admin/pages/new">New page</LinkButton>
|
||||
</div>
|
||||
|
||||
{sp.deleted === "1" && <Flash>Page deleted.</Flash>}
|
||||
|
||||
{pages.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-edge-strong px-4 py-10 text-center text-sm text-ink-muted">
|
||||
No static pages yet. Create an About page, perhaps?
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-edge bg-surface">
|
||||
<table className="w-full min-w-[38rem] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-edge text-left text-xs uppercase tracking-wider text-ink-muted">
|
||||
<th scope="col" className="px-4 py-3 font-medium">Title</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">Status</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">Updated</th>
|
||||
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-edge">
|
||||
{pages.map((page) => (
|
||||
<tr key={page.id}>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/pages/${page.id}/edit`}
|
||||
className="font-medium text-ink-strong transition-colors hover:text-link"
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
<span className="mt-0.5 block font-mono text-xs text-ink-muted">
|
||||
/pages/{page.slug}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={page.status} /></td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
|
||||
{formatDate(page.updatedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link
|
||||
href={`/admin/pages/${page.id}/preview`}
|
||||
className={actionButtonClasses}
|
||||
>
|
||||
Preview
|
||||
</Link>
|
||||
<form
|
||||
action={setPageStatusAction.bind(
|
||||
null,
|
||||
page.id,
|
||||
page.status === "published" ? "draft" : "published",
|
||||
)}
|
||||
>
|
||||
<button type="submit" className={actionButtonClasses}>
|
||||
{page.status === "published" ? "Unpublish" : "Publish"}
|
||||
</button>
|
||||
</form>
|
||||
<form action={deletePageAction.bind(null, page.id)}>
|
||||
<ConfirmButton
|
||||
confirmMessage={`Delete “${page.title}”? Navigation items pointing at it will be removed too. This cannot be undone.`}
|
||||
className="border-none px-2 py-1 text-xs"
|
||||
>
|
||||
Delete
|
||||
</ConfirmButton>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/app/admin/(panel)/posts/[id]/edit/page.tsx
Normal file
62
src/app/admin/(panel)/posts/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { updatePostAction } from "@/actions/posts";
|
||||
import { Flash } from "@/components/admin/Flash";
|
||||
import { PostForm } from "@/components/admin/PostForm";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { parseIdParam } from "@/lib/params";
|
||||
import { getPostById } from "@/lib/services/posts";
|
||||
import { listAllTags } from "@/lib/services/tags";
|
||||
|
||||
export const metadata: Metadata = { title: "Edit post" };
|
||||
|
||||
export default async function EditPostPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const user = await requireAdmin();
|
||||
const [{ id: rawId }, sp] = await Promise.all([params, searchParams]);
|
||||
|
||||
const id = parseIdParam(rawId);
|
||||
if (id === null) notFound();
|
||||
|
||||
const [post, allTags] = await Promise.all([getPostById(id), listAllTags()]);
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Edit post</h1>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link
|
||||
href={`/admin/posts/${post.id}/preview`}
|
||||
className="text-ink-muted transition-colors hover:text-link"
|
||||
>
|
||||
Preview
|
||||
</Link>
|
||||
{post.status === "published" && (
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="text-ink-muted transition-colors hover:text-link"
|
||||
>
|
||||
View on site
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sp.saved === "1" && <Flash>Saved.</Flash>}
|
||||
|
||||
<PostForm
|
||||
post={post}
|
||||
allTags={allTags}
|
||||
defaultAuthor={user.username}
|
||||
action={updatePostAction.bind(null, post.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/app/admin/(panel)/posts/[id]/preview/page.tsx
Normal file
43
src/app/admin/(panel)/posts/[id]/preview/page.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { PostArticle } from "@/components/public/PostArticle";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { parseIdParam } from "@/lib/params";
|
||||
import { getPostById } from "@/lib/services/posts";
|
||||
|
||||
export const metadata: Metadata = { title: "Preview post" };
|
||||
|
||||
/** Renders the post exactly as the public site would — drafts included. */
|
||||
export default async function PostPreviewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
await requireAdmin();
|
||||
const { id: rawId } = await params;
|
||||
const id = parseIdParam(rawId);
|
||||
if (id === null) notFound();
|
||||
|
||||
const post = await getPostById(id);
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
|
||||
<p className="flex items-center gap-2 text-warning">
|
||||
<span className="font-medium">Preview</span>
|
||||
<StatusBadge status={post.status} />
|
||||
</p>
|
||||
<Link
|
||||
href={`/admin/posts/${post.id}/edit`}
|
||||
className="font-medium text-warning underline underline-offset-4"
|
||||
>
|
||||
Back to editor
|
||||
</Link>
|
||||
</div>
|
||||
<PostArticle post={post} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/app/admin/(panel)/posts/new/page.tsx
Normal file
19
src/app/admin/(panel)/posts/new/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { Metadata } from "next";
|
||||
import { createPostAction } from "@/actions/posts";
|
||||
import { PostForm } from "@/components/admin/PostForm";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { listAllTags } from "@/lib/services/tags";
|
||||
|
||||
export const metadata: Metadata = { title: "New post" };
|
||||
|
||||
export default async function NewPostPage() {
|
||||
const user = await requireAdmin();
|
||||
const allTags = await listAllTags();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">New post</h1>
|
||||
<PostForm allTags={allTags} defaultAuthor={user.username} action={createPostAction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/app/admin/(panel)/posts/page.tsx
Normal file
108
src/app/admin/(panel)/posts/page.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { deletePostAction, setPostStatusAction } from "@/actions/posts";
|
||||
import { ConfirmButton } from "@/components/admin/ConfirmButton";
|
||||
import { Flash } from "@/components/admin/Flash";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { LinkButton } from "@/components/ui";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { listAllPosts } from "@/lib/services/posts";
|
||||
|
||||
export const metadata: Metadata = { title: "Posts" };
|
||||
|
||||
const actionButtonClasses =
|
||||
"rounded-md px-2 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-background hover:text-ink-strong";
|
||||
|
||||
export default async function AdminPostsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
await requireAdmin();
|
||||
const [sp, posts] = await Promise.all([searchParams, listAllPosts()]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Posts</h1>
|
||||
<LinkButton href="/admin/posts/new">New post</LinkButton>
|
||||
</div>
|
||||
|
||||
{sp.deleted === "1" && <Flash>Post deleted.</Flash>}
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-edge-strong px-4 py-10 text-center text-sm text-ink-muted">
|
||||
No posts yet. Create the first one!
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-edge bg-surface">
|
||||
<table className="w-full min-w-[44rem] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-edge text-left text-xs uppercase tracking-wider text-ink-muted">
|
||||
<th scope="col" className="px-4 py-3 font-medium">Title</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">Status</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">Published</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">Updated</th>
|
||||
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-edge">
|
||||
{posts.map((post) => (
|
||||
<tr key={post.id}>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/admin/posts/${post.id}/edit`}
|
||||
className="font-medium text-ink-strong transition-colors hover:text-link"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
<span className="mt-0.5 block font-mono text-xs text-ink-muted">
|
||||
/posts/{post.slug}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={post.status} /></td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
|
||||
{formatDate(post.publishedAt)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
|
||||
{formatDate(post.updatedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link
|
||||
href={`/admin/posts/${post.id}/preview`}
|
||||
className={actionButtonClasses}
|
||||
>
|
||||
Preview
|
||||
</Link>
|
||||
<form
|
||||
action={setPostStatusAction.bind(
|
||||
null,
|
||||
post.id,
|
||||
post.status === "published" ? "draft" : "published",
|
||||
)}
|
||||
>
|
||||
<button type="submit" className={actionButtonClasses}>
|
||||
{post.status === "published" ? "Unpublish" : "Publish"}
|
||||
</button>
|
||||
</form>
|
||||
<form action={deletePostAction.bind(null, post.id)}>
|
||||
<ConfirmButton
|
||||
confirmMessage={`Delete “${post.title}”? This cannot be undone.`}
|
||||
className="border-none px-2 py-1 text-xs"
|
||||
>
|
||||
Delete
|
||||
</ConfirmButton>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/app/admin/(panel)/settings/page.tsx
Normal file
32
src/app/admin/(panel)/settings/page.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { Metadata } from "next";
|
||||
import { updateSettingsAction } from "@/actions/settings";
|
||||
import { SettingsForm } from "@/components/admin/SettingsForm";
|
||||
import { requireAdmin } from "@/lib/auth/dal";
|
||||
import { listPublishedPages } from "@/lib/services/pages";
|
||||
import { getSettings, listNavItems } from "@/lib/services/settings";
|
||||
import { listAllTags } from "@/lib/services/tags";
|
||||
|
||||
export const metadata: Metadata = { title: "Site settings" };
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
await requireAdmin();
|
||||
const [settings, navItems, allTags, publishedPages] = await Promise.all([
|
||||
getSettings(),
|
||||
listNavItems(),
|
||||
listAllTags(),
|
||||
listPublishedPages(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">Site settings</h1>
|
||||
<SettingsForm
|
||||
settings={settings}
|
||||
navItems={navItems}
|
||||
allTags={allTags}
|
||||
publishedPages={publishedPages}
|
||||
action={updateSettingsAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/app/admin/login/page.tsx
Normal file
28
src/app/admin/login/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LoginForm } from "@/components/admin/LoginForm";
|
||||
import { getSessionUser } from "@/lib/auth/dal";
|
||||
|
||||
export const metadata: Metadata = { title: "Sign in" };
|
||||
|
||||
export default async function LoginPage() {
|
||||
const user = await getSessionUser();
|
||||
if (user) redirect("/admin");
|
||||
|
||||
return (
|
||||
<main id="main" className="container-site flex flex-1 items-center justify-center py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="rounded-lg border border-edge bg-surface p-6 sm:p-8">
|
||||
<h1 className="mb-6 text-xl font-semibold text-ink-bright">Admin sign in</h1>
|
||||
<LoginForm />
|
||||
</div>
|
||||
<p className="mt-6 text-center text-sm">
|
||||
<Link href="/" className="text-ink-muted underline underline-offset-4 hover:text-link">
|
||||
← Back to the site
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
39
src/app/api/admin/uploads/route.ts
Normal file
39
src/app/api/admin/uploads/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/auth/dal";
|
||||
import { saveUploadedImage } from "@/lib/uploads";
|
||||
|
||||
/**
|
||||
* Image upload endpoint for the admin editor. JSON errors (not redirects)
|
||||
* because the caller is fetch(), not a browser navigation. Auth is checked
|
||||
* server-side exactly like every admin mutation.
|
||||
*/
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let file: FormDataEntryValue | null = null;
|
||||
try {
|
||||
file = (await request.formData()).get("file");
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Malformed upload request." }, { status: 400 });
|
||||
}
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file was provided." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await saveUploadedImage(file);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status });
|
||||
}
|
||||
return NextResponse.json({ url: `/uploads/${result.filename}` }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("upload failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: "The image could not be saved. Please try again." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
35
src/app/error.tsx
Normal file
35
src/app/error.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Full details stay in the server logs; the digest links the two.
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main id="main" className="container-site flex flex-1 items-center justify-center py-24">
|
||||
<div className="w-full max-w-md rounded-lg border border-edge bg-surface p-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-ink-strong">Something went wrong</h1>
|
||||
<p className="mt-3 text-sm text-ink-muted">
|
||||
An unexpected error occurred. It has been logged on the server
|
||||
{error.digest ? ` (reference ${error.digest})` : ""}.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="mt-6 inline-flex items-center rounded-md bg-link px-4 py-2 text-sm font-medium text-ink-inverse transition-colors hover:bg-link-hover"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
52
src/app/global-error.tsx
Normal file
52
src/app/global-error.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
// Last-resort boundary: rendered when the root layout itself fails
|
||||
// (e.g. the database is unreachable). Must provide its own <html>.
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "#002b36",
|
||||
color: "#839496",
|
||||
fontFamily: "ui-sans-serif, system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", padding: "2rem" }}>
|
||||
<h1 style={{ color: "#93a1a1", fontSize: "1.25rem" }}>The site is unavailable</h1>
|
||||
<p style={{ fontSize: "0.875rem", maxWidth: "28rem" }}>
|
||||
Something went wrong while loading the site
|
||||
{error.digest ? ` (reference ${error.digest})` : ""}. Please try again in a moment.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: "1rem",
|
||||
background: "#268bd2",
|
||||
color: "#002b36",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,26 +1,591 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Theming architecture
|
||||
* --------------------
|
||||
* 1. Raw Solarized palette (--sol-*). Components never reference these.
|
||||
* 2. Semantic tokens (--background, --ink, ...) — the theme contract.
|
||||
* :root carries Solarized Dark; each additional theme overrides ONLY
|
||||
* the tokens, keyed by the data-theme attribute the root layout sets
|
||||
* on <html> from the site settings (see [data-theme="solarized-light"]
|
||||
* below). The eight Solarized accent colors are shared by design —
|
||||
* only the base tones flip between dark and light.
|
||||
* 3. `@theme inline` exposes the semantic tokens as Tailwind utilities
|
||||
* (bg-background, text-ink, border-edge, ...). The generated utilities
|
||||
* resolve through var() at runtime, so swapping tokens re-skins the app
|
||||
* without touching any component.
|
||||
*
|
||||
* Fonts follow the same pattern: --font-body defaults to Geist and is
|
||||
* remapped per [data-font="..."]; Tailwind's font-sans resolves through it.
|
||||
*
|
||||
* Adding a theme = one override block here + one enum value in
|
||||
* src/db/schema.ts + one entry in src/lib/themes.ts (the Record type
|
||||
* makes a missing entry a compile error). Fonts additionally need a
|
||||
* next/font instance in src/app/layout.tsx.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--sol-base03: #002b36;
|
||||
--sol-base02: #073642;
|
||||
--sol-base01: #586e75;
|
||||
--sol-base00: #657b83;
|
||||
--sol-base0: #839496;
|
||||
--sol-base1: #93a1a1;
|
||||
--sol-base2: #eee8d5;
|
||||
--sol-base3: #fdf6e3;
|
||||
--sol-yellow: #b58900;
|
||||
--sol-orange: #cb4b16;
|
||||
--sol-red: #dc322f;
|
||||
--sol-magenta: #d33682;
|
||||
--sol-violet: #6c71c4;
|
||||
--sol-blue: #268bd2;
|
||||
--sol-cyan: #2aa198;
|
||||
--sol-green: #859900;
|
||||
|
||||
/* Semantic tokens (Solarized Dark) */
|
||||
--background: var(--sol-base03);
|
||||
--surface: var(--sol-base02);
|
||||
--edge: color-mix(in oklab, var(--sol-base02) 70%, var(--sol-base01));
|
||||
--edge-strong: color-mix(in oklab, var(--sol-base02) 35%, var(--sol-base01));
|
||||
--ink: var(--sol-base0);
|
||||
--ink-muted: color-mix(in oklab, var(--sol-base01) 78%, var(--sol-base0));
|
||||
--ink-strong: var(--sol-base1);
|
||||
--ink-bright: var(--sol-base2);
|
||||
--ink-inverse: var(--sol-base03);
|
||||
--link: var(--sol-blue);
|
||||
--link-hover: var(--sol-cyan);
|
||||
--accent: var(--sol-violet);
|
||||
--danger: var(--sol-red);
|
||||
--warning: var(--sol-yellow);
|
||||
--success: var(--sol-green);
|
||||
--code: var(--sol-cyan);
|
||||
--focus: var(--sol-blue);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--color-surface: var(--surface);
|
||||
--color-edge: var(--edge);
|
||||
--color-edge-strong: var(--edge-strong);
|
||||
--color-ink: var(--ink);
|
||||
--color-ink-muted: var(--ink-muted);
|
||||
--color-ink-strong: var(--ink-strong);
|
||||
--color-ink-bright: var(--ink-bright);
|
||||
--color-ink-inverse: var(--ink-inverse);
|
||||
--color-link: var(--link);
|
||||
--color-link-hover: var(--link-hover);
|
||||
--color-accent: var(--accent);
|
||||
--color-danger: var(--danger);
|
||||
--color-warning: var(--warning);
|
||||
--color-success: var(--success);
|
||||
--color-code: var(--code);
|
||||
--color-focus: var(--focus);
|
||||
--font-sans: var(--font-body);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
/* Solarized Light: same accents, base tones mirrored around the midpoint. */
|
||||
[data-theme="solarized-light"] {
|
||||
color-scheme: light;
|
||||
--background: var(--sol-base3);
|
||||
--surface: var(--sol-base2);
|
||||
--edge: color-mix(in oklab, var(--sol-base2) 70%, var(--sol-base1));
|
||||
--edge-strong: color-mix(in oklab, var(--sol-base2) 35%, var(--sol-base1));
|
||||
--ink: var(--sol-base00);
|
||||
--ink-muted: color-mix(in oklab, var(--sol-base1) 55%, var(--sol-base00));
|
||||
--ink-strong: var(--sol-base01);
|
||||
--ink-bright: var(--sol-base02);
|
||||
/* --ink-inverse stays base03: dark-on-blue keeps AA-ish contrast in both themes. */
|
||||
}
|
||||
|
||||
/* Dracula — https://draculatheme.com (bg 282a36, fg f8f8f2, comment 6272a4) */
|
||||
[data-theme="dracula"] {
|
||||
color-scheme: dark;
|
||||
--background: #282a36;
|
||||
--surface: #21222c;
|
||||
--edge: color-mix(in oklab, #21222c 65%, #6272a4);
|
||||
--edge-strong: color-mix(in oklab, #21222c 30%, #6272a4);
|
||||
--ink: color-mix(in oklab, #f8f8f2 78%, #6272a4);
|
||||
--ink-muted: #6272a4;
|
||||
--ink-strong: #f8f8f2;
|
||||
--ink-bright: #ffffff;
|
||||
--ink-inverse: #282a36;
|
||||
--link: #bd93f9;
|
||||
--link-hover: #ff79c6;
|
||||
--accent: #8be9fd;
|
||||
--danger: #ff5555;
|
||||
--warning: #f1fa8c;
|
||||
--success: #50fa7b;
|
||||
--code: #8be9fd;
|
||||
--focus: #bd93f9;
|
||||
}
|
||||
|
||||
/* Nord — https://nordtheme.com (Polar Night / Snow Storm / Frost / Aurora) */
|
||||
[data-theme="nord"] {
|
||||
color-scheme: dark;
|
||||
--background: #2e3440;
|
||||
--surface: #3b4252;
|
||||
--edge: #434c5e;
|
||||
--edge-strong: #4c566a;
|
||||
--ink: #d8dee9;
|
||||
--ink-muted: color-mix(in oklab, #4c566a 45%, #d8dee9);
|
||||
--ink-strong: #e5e9f0;
|
||||
--ink-bright: #eceff4;
|
||||
--ink-inverse: #2e3440;
|
||||
--link: #88c0d0;
|
||||
--link-hover: #8fbcbb;
|
||||
--accent: #b48ead;
|
||||
--danger: #bf616a;
|
||||
--warning: #ebcb8b;
|
||||
--success: #a3be8c;
|
||||
--code: #8fbcbb;
|
||||
--focus: #88c0d0;
|
||||
}
|
||||
|
||||
/* Gruvbox Dark — https://github.com/morhetz/gruvbox (medium contrast) */
|
||||
[data-theme="gruvbox-dark"] {
|
||||
color-scheme: dark;
|
||||
--background: #282828;
|
||||
--surface: #3c3836;
|
||||
--edge: color-mix(in oklab, #3c3836 65%, #928374);
|
||||
--edge-strong: color-mix(in oklab, #3c3836 30%, #928374);
|
||||
--ink: #d5c4a1;
|
||||
--ink-muted: #928374;
|
||||
--ink-strong: #ebdbb2;
|
||||
--ink-bright: #fbf1c7;
|
||||
--ink-inverse: #282828;
|
||||
--link: #fe8019;
|
||||
--link-hover: #fabd2f;
|
||||
--accent: #d3869b;
|
||||
--danger: #fb4934;
|
||||
--warning: #fabd2f;
|
||||
--success: #b8bb26;
|
||||
--code: #8ec07c;
|
||||
--focus: #fe8019;
|
||||
}
|
||||
|
||||
/* Catppuccin Mocha — https://catppuccin.com (base/mantle + pastel accents) */
|
||||
[data-theme="catppuccin-mocha"] {
|
||||
color-scheme: dark;
|
||||
--background: #1e1e2e;
|
||||
--surface: #181825;
|
||||
--edge: color-mix(in oklab, #181825 65%, #6c7086);
|
||||
--edge-strong: color-mix(in oklab, #181825 35%, #6c7086);
|
||||
--ink: #bac2de;
|
||||
--ink-muted: color-mix(in oklab, #6c7086 70%, #a6adc8);
|
||||
--ink-strong: #cdd6f4;
|
||||
--ink-bright: color-mix(in oklab, #cdd6f4 70%, white);
|
||||
--ink-inverse: #1e1e2e;
|
||||
--link: #89b4fa;
|
||||
--link-hover: #b4befe;
|
||||
--accent: #cba6f7;
|
||||
--danger: #f38ba8;
|
||||
--warning: #f9e2af;
|
||||
--success: #a6e3a1;
|
||||
--code: #94e2d5;
|
||||
--focus: #89b4fa;
|
||||
}
|
||||
|
||||
/* Catppuccin Latte — the light Catppuccin flavor */
|
||||
[data-theme="catppuccin-latte"] {
|
||||
color-scheme: light;
|
||||
--background: #eff1f5;
|
||||
--surface: #e6e9ef;
|
||||
--edge: #ccd0da;
|
||||
--edge-strong: #acb0be;
|
||||
--ink: #5c5f77;
|
||||
--ink-muted: #8c8fa1;
|
||||
--ink-strong: #4c4f69;
|
||||
--ink-bright: color-mix(in oklab, #4c4f69 75%, black);
|
||||
--ink-inverse: #eff1f5;
|
||||
--link: #1e66f5;
|
||||
--link-hover: #8839ef;
|
||||
--accent: #8839ef;
|
||||
--danger: #d20f39;
|
||||
--warning: #df8e1d;
|
||||
--success: #40a02b;
|
||||
--code: #179299;
|
||||
--focus: #1e66f5;
|
||||
}
|
||||
|
||||
/* Tokyo Night — https://github.com/tokyo-night (storm-free classic) */
|
||||
[data-theme="tokyo-night"] {
|
||||
color-scheme: dark;
|
||||
--background: #1a1b26;
|
||||
--surface: #16161e;
|
||||
--edge: #292e42;
|
||||
--edge-strong: #3b4261;
|
||||
--ink: #a9b1d6;
|
||||
--ink-muted: #565f89;
|
||||
--ink-strong: #c0caf5;
|
||||
--ink-bright: color-mix(in oklab, #c0caf5 75%, white);
|
||||
--ink-inverse: #1a1b26;
|
||||
--link: #7aa2f7;
|
||||
--link-hover: #7dcfff;
|
||||
--accent: #bb9af7;
|
||||
--danger: #f7768e;
|
||||
--warning: #e0af68;
|
||||
--success: #9ece6a;
|
||||
--code: #7dcfff;
|
||||
--focus: #7aa2f7;
|
||||
}
|
||||
|
||||
/* One Dark — Atom's classic */
|
||||
[data-theme="one-dark"] {
|
||||
color-scheme: dark;
|
||||
--background: #282c34;
|
||||
--surface: #21252b;
|
||||
--edge: color-mix(in oklab, #21252b 60%, #4b5263);
|
||||
--edge-strong: #4b5263;
|
||||
--ink: #abb2bf;
|
||||
--ink-muted: #5c6370;
|
||||
--ink-strong: #d7dae0;
|
||||
--ink-bright: color-mix(in oklab, #d7dae0 70%, white);
|
||||
--ink-inverse: #282c34;
|
||||
--link: #61afef;
|
||||
--link-hover: #56b6c2;
|
||||
--accent: #c678dd;
|
||||
--danger: #e06c75;
|
||||
--warning: #e5c07b;
|
||||
--success: #98c379;
|
||||
--code: #56b6c2;
|
||||
--focus: #61afef;
|
||||
}
|
||||
|
||||
/* Rosé Pine — https://rosepinetheme.com (main variant) */
|
||||
[data-theme="rose-pine"] {
|
||||
color-scheme: dark;
|
||||
--background: #191724;
|
||||
--surface: #1f1d2e;
|
||||
--edge: #26233a;
|
||||
--edge-strong: #403d52;
|
||||
--ink: color-mix(in oklab, #e0def4 75%, #908caa);
|
||||
--ink-muted: #908caa;
|
||||
--ink-strong: #e0def4;
|
||||
--ink-bright: color-mix(in oklab, #e0def4 75%, white);
|
||||
--ink-inverse: #191724;
|
||||
--link: #c4a7e7;
|
||||
--link-hover: #ebbcba;
|
||||
--accent: #9ccfd8;
|
||||
--danger: #eb6f92;
|
||||
--warning: #f6c177;
|
||||
--success: #9ccfd8;
|
||||
--code: #ebbcba;
|
||||
--focus: #c4a7e7;
|
||||
}
|
||||
|
||||
/* Everforest Dark — https://github.com/sainnhe/everforest (medium) */
|
||||
[data-theme="everforest-dark"] {
|
||||
color-scheme: dark;
|
||||
--background: #2d353b;
|
||||
--surface: #343f44;
|
||||
--edge: #475258;
|
||||
--edge-strong: #4f585e;
|
||||
--ink: color-mix(in oklab, #d3c6aa 82%, #859289);
|
||||
--ink-muted: #859289;
|
||||
--ink-strong: #d3c6aa;
|
||||
--ink-bright: color-mix(in oklab, #d3c6aa 78%, white);
|
||||
--ink-inverse: #2d353b;
|
||||
--link: #7fbbb3;
|
||||
--link-hover: #83c092;
|
||||
--accent: #d699b6;
|
||||
--danger: #e67e80;
|
||||
--warning: #dbbc7f;
|
||||
--success: #a7c080;
|
||||
--code: #83c092;
|
||||
--focus: #7fbbb3;
|
||||
}
|
||||
|
||||
/* Monokai — the TextMate/Sublime classic */
|
||||
[data-theme="monokai"] {
|
||||
color-scheme: dark;
|
||||
--background: #272822;
|
||||
--surface: #1e1f1c;
|
||||
--edge: #3e3d32;
|
||||
--edge-strong: #57584f;
|
||||
--ink: color-mix(in oklab, #f8f8f2 78%, #75715e);
|
||||
--ink-muted: #75715e;
|
||||
--ink-strong: #f8f8f2;
|
||||
--ink-bright: #ffffff;
|
||||
--ink-inverse: #272822;
|
||||
--link: #66d9ef;
|
||||
--link-hover: #a6e22e;
|
||||
--accent: #ae81ff;
|
||||
--danger: #f92672;
|
||||
--warning: #e6db74;
|
||||
--success: #a6e22e;
|
||||
--code: #e6db74;
|
||||
--focus: #66d9ef;
|
||||
}
|
||||
|
||||
/* GitHub Light — the default github.com palette */
|
||||
[data-theme="github-light"] {
|
||||
color-scheme: light;
|
||||
--background: #ffffff;
|
||||
--surface: #f6f8fa;
|
||||
--edge: #d8dee4;
|
||||
--edge-strong: #afb8c1;
|
||||
--ink: #24292f;
|
||||
--ink-muted: #656d76;
|
||||
--ink-strong: #1f2328;
|
||||
--ink-bright: #000000;
|
||||
--ink-inverse: #ffffff;
|
||||
--link: #0969da;
|
||||
--link-hover: #0550ae;
|
||||
--accent: #8250df;
|
||||
--danger: #cf222e;
|
||||
--warning: #9a6700;
|
||||
--success: #1a7f37;
|
||||
--code: #953800;
|
||||
--focus: #0969da;
|
||||
}
|
||||
|
||||
/* White on Black — the inverted companion to Black & White. */
|
||||
[data-theme="mono-dark"] {
|
||||
color-scheme: dark;
|
||||
--background: #0a0a0a;
|
||||
--surface: #171717;
|
||||
--edge: #2e2e2e;
|
||||
--edge-strong: #454545;
|
||||
--ink: #d4d4d4;
|
||||
--ink-muted: #8a8a8a;
|
||||
--ink-strong: #f5f5f5;
|
||||
--ink-bright: #ffffff;
|
||||
--ink-inverse: #0a0a0a;
|
||||
--link: #ffffff;
|
||||
--link-hover: #b3b3b3;
|
||||
--accent: #ffffff;
|
||||
--danger: #f0f0f0;
|
||||
--warning: #bdbdbd;
|
||||
--success: #f0f0f0;
|
||||
--code: #fafafa;
|
||||
--focus: #ffffff;
|
||||
}
|
||||
|
||||
/* Black & White — plain paper: grayscale only, by design. */
|
||||
[data-theme="mono"] {
|
||||
color-scheme: light;
|
||||
--background: #ffffff;
|
||||
--surface: #f5f5f5;
|
||||
--edge: #e2e2e2;
|
||||
--edge-strong: #c6c6c6;
|
||||
--ink: #333333;
|
||||
--ink-muted: #6e6e6e;
|
||||
--ink-strong: #111111;
|
||||
--ink-bright: #000000;
|
||||
--ink-inverse: #ffffff;
|
||||
--link: #000000;
|
||||
--link-hover: #555555;
|
||||
--accent: #000000;
|
||||
--danger: #1a1a1a;
|
||||
--warning: #4a4a4a;
|
||||
--success: #1a1a1a;
|
||||
--code: #111111;
|
||||
--focus: #000000;
|
||||
}
|
||||
|
||||
/* Body font: default Geist, remapped per data-font (see layout.tsx). */
|
||||
:root {
|
||||
--font-body: var(--font-geist-sans);
|
||||
}
|
||||
[data-font="inter"] {
|
||||
--font-body: var(--font-inter);
|
||||
}
|
||||
[data-font="lora"] {
|
||||
--font-body: var(--font-lora);
|
||||
}
|
||||
[data-font="merriweather"] {
|
||||
--font-body: var(--font-merriweather);
|
||||
}
|
||||
[data-font="jetbrains-mono"] {
|
||||
--font-body: var(--font-jetbrains-mono);
|
||||
}
|
||||
[data-font="source-serif"] {
|
||||
--font-body: var(--font-source-serif);
|
||||
}
|
||||
[data-font="eb-garamond"] {
|
||||
--font-body: var(--font-eb-garamond);
|
||||
}
|
||||
[data-font="playfair-display"] {
|
||||
--font-body: var(--font-playfair);
|
||||
}
|
||||
[data-font="open-sans"] {
|
||||
--font-body: var(--font-open-sans);
|
||||
}
|
||||
[data-font="work-sans"] {
|
||||
--font-body: var(--font-work-sans);
|
||||
}
|
||||
[data-font="atkinson-hyperlegible"] {
|
||||
--font-body: var(--font-atkinson);
|
||||
}
|
||||
[data-font="space-grotesk"] {
|
||||
--font-body: var(--font-space-grotesk);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Token-driven so every theme gets a sensible selection color. */
|
||||
::selection {
|
||||
background: var(--link);
|
||||
color: var(--ink-inverse);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.container-site {
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.container-site {
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Rendered Markdown (posts, pages, editor preview) */
|
||||
.markdown-body {
|
||||
line-height: 1.75;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.markdown-body > * + * {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
color: var(--ink-strong);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-top: 1.6em;
|
||||
}
|
||||
.markdown-body h1 { font-size: 1.6rem; }
|
||||
.markdown-body h2 { font-size: 1.35rem; }
|
||||
.markdown-body h3 { font-size: 1.15rem; }
|
||||
.markdown-body h4 { font-size: 1rem; }
|
||||
.markdown-body a {
|
||||
color: var(--link);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.markdown-body a:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
.markdown-body strong {
|
||||
color: var(--ink-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.markdown-body ul { list-style: disc; }
|
||||
.markdown-body ol { list-style: decimal; }
|
||||
.markdown-body li + li { margin-top: 0.35em; }
|
||||
.markdown-body li::marker { color: var(--ink-muted); }
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--ink-muted);
|
||||
padding-left: 1rem;
|
||||
color: var(--ink-strong);
|
||||
font-style: italic;
|
||||
}
|
||||
.markdown-body code {
|
||||
font-family: var(--font-geist-mono), monospace;
|
||||
font-size: 0.875em;
|
||||
color: var(--code);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 4px;
|
||||
padding: 0.125em 0.375em;
|
||||
}
|
||||
.markdown-body pre {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.markdown-body pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--ink-strong);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: 1px solid var(--edge-strong);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-body th {
|
||||
background: var(--surface);
|
||||
color: var(--ink-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--edge);
|
||||
}
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--edge-strong);
|
||||
margin-block: 2rem;
|
||||
}
|
||||
|
||||
/* Rich text editor (Tiptap) chrome */
|
||||
.editor-shell:focus-within {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.editor-shell .tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.editor-shell .tiptap img.ProseMirror-selectednode {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.editor-shell .tiptap .selectedCell {
|
||||
position: relative;
|
||||
}
|
||||
.editor-shell .tiptap .selectedCell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: color-mix(in oklab, var(--focus) 18%, transparent);
|
||||
}
|
||||
.editor-shell .tiptap table {
|
||||
display: table; /* editable tables need real table layout, not scroll wrapper */
|
||||
}
|
||||
.editor-shell .tiptap .ProseMirror-gapcursor:after {
|
||||
border-top: 1px solid var(--ink-strong);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,33 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import {
|
||||
Atkinson_Hyperlegible,
|
||||
EB_Garamond,
|
||||
Geist,
|
||||
Geist_Mono,
|
||||
Inter,
|
||||
JetBrains_Mono,
|
||||
Lora,
|
||||
Merriweather,
|
||||
Open_Sans,
|
||||
Playfair_Display,
|
||||
Source_Serif_4,
|
||||
Space_Grotesk,
|
||||
Work_Sans,
|
||||
} from "next/font/google";
|
||||
import "./globals.css";
|
||||
import type { Settings } from "@/db/schema";
|
||||
import { DEFAULT_SETTINGS, getSettings } from "@/lib/services/settings";
|
||||
import { THEME_META } from "@/lib/themes";
|
||||
|
||||
// The entire site is driven by database content that admins change at any
|
||||
// time, so every route renders per request (no build-time DB dependency).
|
||||
// Incremental caching/revalidation is a documented future optimization.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// All selectable body fonts are self-hosted by next/font at build time.
|
||||
// Only Geist (the default) is preloaded; the others declare @font-face
|
||||
// rules and are fetched by the browser solely when the admin-selected
|
||||
// data-font attribute makes one of them the active --font-body.
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
|
|
@ -12,22 +38,143 @@ const geistMono = Geist_Mono({
|
|||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
const lora = Lora({
|
||||
variable: "--font-lora",
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const merriweather = Merriweather({
|
||||
variable: "--font-merriweather",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "700"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-jetbrains-mono",
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const sourceSerif = Source_Serif_4({
|
||||
variable: "--font-source-serif",
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const ebGaramond = EB_Garamond({
|
||||
variable: "--font-eb-garamond",
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
variable: "--font-playfair",
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const openSans = Open_Sans({
|
||||
variable: "--font-open-sans",
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const workSans = Work_Sans({
|
||||
variable: "--font-work-sans",
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const atkinson = Atkinson_Hyperlegible({
|
||||
variable: "--font-atkinson",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
style: ["normal", "italic"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
variable: "--font-space-grotesk",
|
||||
subsets: ["latin"],
|
||||
preload: false,
|
||||
});
|
||||
|
||||
const fontVariables = [
|
||||
geistSans.variable,
|
||||
geistMono.variable,
|
||||
inter.variable,
|
||||
lora.variable,
|
||||
merriweather.variable,
|
||||
jetbrainsMono.variable,
|
||||
sourceSerif.variable,
|
||||
ebGaramond.variable,
|
||||
playfair.variable,
|
||||
openSans.variable,
|
||||
workSans.variable,
|
||||
atkinson.variable,
|
||||
spaceGrotesk.variable,
|
||||
].join(" ");
|
||||
|
||||
/** The chrome must render even when the DB is down; fall back to defaults. */
|
||||
async function settingsOrDefaults(): Promise<Settings> {
|
||||
try {
|
||||
return await getSettings();
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await settingsOrDefaults();
|
||||
return {
|
||||
title: { default: settings.siteTitle, template: `%s · ${settings.siteTitle}` },
|
||||
description: settings.headerText || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
const { theme } = await settingsOrDefaults();
|
||||
return { themeColor: THEME_META[theme].bg };
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const { theme, font } = await settingsOrDefaults();
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
data-theme={theme}
|
||||
data-font={font}
|
||||
className={`${fontVariables} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="flex min-h-dvh flex-col font-sans">
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-link focus:px-4 focus:py-2 focus:text-ink-inverse"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
24
src/app/not-found.tsx
Normal file
24
src/app/not-found.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import Link from "next/link";
|
||||
|
||||
// Fallback 404 for URLs outside the public group (e.g. bad /admin paths).
|
||||
// Public-site 404s use src/app/(public)/not-found.tsx, which keeps the
|
||||
// header, sidebar, and footer around the message.
|
||||
export default function RootNotFound() {
|
||||
return (
|
||||
<main id="main" className="container-site flex flex-1 items-center justify-center py-24">
|
||||
<div className="text-center">
|
||||
<p className="font-mono text-sm text-ink-muted">404</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-ink-strong">Page not found</h1>
|
||||
<p className="mt-3 text-sm text-ink-muted">
|
||||
The page you are looking for does not exist or has been removed.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex items-center rounded-md bg-link px-4 py-2 text-sm font-medium text-ink-inverse transition-colors hover:bg-link-hover"
|
||||
>
|
||||
Back to the blog
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
37
src/app/uploads/[name]/route.ts
Normal file
37
src/app/uploads/[name]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { MIME_BY_EXTENSION, UPLOAD_NAME_PATTERN } from "@/lib/uploads";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Serves editor-uploaded images from the uploads directory. The filename
|
||||
* pattern is locked to what saveUploadedImage generates (UUID + known
|
||||
* extension), which rules out path traversal by construction. UUID names
|
||||
* never change content, so responses are immutable-cacheable.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
): Promise<Response> {
|
||||
const { name } = await params;
|
||||
if (!UPLOAD_NAME_PATTERN.test(name)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
let data: Buffer;
|
||||
try {
|
||||
data = await readFile(path.join(process.cwd(), "uploads", name));
|
||||
} catch {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const extension = path.extname(name).slice(1);
|
||||
return new Response(new Uint8Array(data), {
|
||||
headers: {
|
||||
"Content-Type": MIME_BY_EXTENSION[extension] ?? "application/octet-stream",
|
||||
"Content-Length": String(data.byteLength),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
}
|
||||
33
src/components/admin/ConfirmButton.tsx
Normal file
33
src/components/admin/ConfirmButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { Button, type ButtonVariant } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Submit button for destructive form actions that asks for confirmation
|
||||
* first. window.confirm is fully keyboard-accessible and needs no extra
|
||||
* dialog plumbing — the right size for this MVP.
|
||||
*/
|
||||
export function ConfirmButton({
|
||||
confirmMessage,
|
||||
children,
|
||||
variant = "danger",
|
||||
className,
|
||||
}: {
|
||||
confirmMessage: string;
|
||||
children: React.ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
variant={variant}
|
||||
className={className}
|
||||
onClick={(event) => {
|
||||
if (!window.confirm(confirmMessage)) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
22
src/components/admin/Flash.tsx
Normal file
22
src/components/admin/Flash.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export function Flash({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-6 rounded-md border border-success/40 bg-success/10 px-4 py-2.5 text-sm text-success"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormErrorBanner({ children }: { children?: React.ReactNode }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/40 bg-danger/10 px-4 py-2.5 text-sm text-danger"
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
85
src/components/admin/FormTabs.tsx
Normal file
85
src/components/admin/FormTabs.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { cx } from "@/components/ui";
|
||||
|
||||
export type FormTabDef = {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Shows a dot on the tab when its panel contains validation errors. */
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* ARIA tabs for the editor forms. Panels are rendered by the parent with
|
||||
* `hidden` (never unmounted) so every input keeps its value and still
|
||||
* submits with the form regardless of which tab is visible.
|
||||
*/
|
||||
export function FormTabs({
|
||||
tabs,
|
||||
activeId,
|
||||
onSelect,
|
||||
idBase,
|
||||
label,
|
||||
}: {
|
||||
tabs: FormTabDef[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
idBase: string;
|
||||
label: string;
|
||||
}) {
|
||||
const buttonsRef = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
|
||||
function focusAndSelect(index: number) {
|
||||
const tab = tabs[(index + tabs.length) % tabs.length];
|
||||
buttonsRef.current.get(tab.id)?.focus();
|
||||
onSelect(tab.id);
|
||||
}
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent, index: number) {
|
||||
if (event.key === "ArrowRight") focusAndSelect(index + 1);
|
||||
else if (event.key === "ArrowLeft") focusAndSelect(index - 1);
|
||||
else if (event.key === "Home") focusAndSelect(0);
|
||||
else if (event.key === "End") focusAndSelect(tabs.length - 1);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="tablist" aria-label={label} className="flex items-end gap-1">
|
||||
{tabs.map((tab, index) => {
|
||||
const active = tab.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) buttonsRef.current.set(tab.id, el);
|
||||
else buttonsRef.current.delete(tab.id);
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${idBase}-tab-${tab.id}`}
|
||||
aria-selected={active}
|
||||
aria-controls={`${idBase}-panel-${tab.id}`}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
onKeyDown={(event) => onKeyDown(event, index)}
|
||||
className={cx(
|
||||
"inline-flex items-center gap-1.5 rounded-t-md border-b-2 px-3.5 py-2 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-link text-ink-strong"
|
||||
: "border-transparent text-ink-muted hover:text-ink-strong",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.hasError && (
|
||||
<span className="size-1.5 rounded-full bg-danger">
|
||||
<span className="sr-only">(contains errors)</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/admin/LoginForm.tsx
Normal file
44
src/components/admin/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { useActionState, useId } from "react";
|
||||
import { loginAction } from "@/actions/auth";
|
||||
import { FormErrorBanner } from "@/components/admin/Flash";
|
||||
import { SubmitButton } from "@/components/admin/SubmitButton";
|
||||
import { ErrorText, Input, Label } from "@/components/ui";
|
||||
import { firstFieldError, initialFormState } from "@/lib/forms";
|
||||
|
||||
export function LoginForm() {
|
||||
const [state, formAction] = useActionState(loginAction, initialFormState);
|
||||
const ids = useId();
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-5">
|
||||
<FormErrorBanner>{state.formError}</FormErrorBanner>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-username`}>Username</Label>
|
||||
<Input
|
||||
id={`${ids}-username`}
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<ErrorText>{firstFieldError(state, "username")}</ErrorText>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-password`}>Password</Label>
|
||||
<Input
|
||||
id={`${ids}-password`}
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<ErrorText>{firstFieldError(state, "password")}</ErrorText>
|
||||
</div>
|
||||
<SubmitButton pendingText="Signing in…" className="w-full">
|
||||
Sign in
|
||||
</SubmitButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
150
src/components/admin/PageForm.tsx
Normal file
150
src/components/admin/PageForm.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useId, useState } from "react";
|
||||
import { FormErrorBanner } from "@/components/admin/Flash";
|
||||
import { FormTabs } from "@/components/admin/FormTabs";
|
||||
import { RichTextEditor } from "@/components/admin/RichTextEditor";
|
||||
import { SubmitButton } from "@/components/admin/SubmitButton";
|
||||
import { ErrorText, HelpText, Input, Label, Select } from "@/components/ui";
|
||||
import type { Page } from "@/db/schema";
|
||||
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
|
||||
import { slugify } from "@/lib/slug";
|
||||
|
||||
type Props = {
|
||||
page?: Page;
|
||||
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
||||
};
|
||||
|
||||
const SETTINGS_FIELDS = ["title", "slug"];
|
||||
|
||||
/** Same tabbed layout as PostForm: full-height canvas + settings panel. */
|
||||
export function PageForm({ page, action }: Props) {
|
||||
const [state, formAction] = useActionState(action, initialFormState);
|
||||
const ids = useId();
|
||||
|
||||
// See PostForm: explicit tab clicks win until the next action result,
|
||||
// which routes to the tab containing validation errors.
|
||||
const [tabChoice, setTabChoice] = useState<{
|
||||
tab: "content" | "settings";
|
||||
forState: FormState;
|
||||
}>({ tab: "content", forState: initialFormState });
|
||||
const [title, setTitle] = useState(page?.title ?? "");
|
||||
const [slug, setSlug] = useState(page?.slug ?? "");
|
||||
const [slugTouched, setSlugTouched] = useState(page !== undefined);
|
||||
const [status, setStatus] = useState<string>(page?.status ?? "draft");
|
||||
|
||||
const err = (field: string) => firstFieldError(state, field);
|
||||
const errorKeys = Object.keys(state.fieldErrors ?? {});
|
||||
const settingsHasError = errorKeys.some((key) => SETTINGS_FIELDS.includes(key));
|
||||
const contentHasError = errorKeys.includes("body");
|
||||
|
||||
const tab =
|
||||
tabChoice.forState === state
|
||||
? tabChoice.tab
|
||||
: settingsHasError
|
||||
? "settings"
|
||||
: contentHasError
|
||||
? "content"
|
||||
: tabChoice.tab;
|
||||
const setTab = (next: "content" | "settings") =>
|
||||
setTabChoice({ tab: next, forState: state });
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<div className="sticky top-0 z-20 -mb-px flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-edge bg-background pt-1">
|
||||
<FormTabs
|
||||
idBase={ids}
|
||||
label="Page editor sections"
|
||||
activeId={tab}
|
||||
onSelect={(id) => setTab(id as typeof tab)}
|
||||
tabs={[
|
||||
{ id: "content", label: "Content", hasError: contentHasError },
|
||||
{ id: "settings", label: "Page settings", hasError: settingsHasError },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-3 pb-1.5">
|
||||
<Select
|
||||
aria-label="Status"
|
||||
name="status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-32"
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
</Select>
|
||||
<SubmitButton>Save page</SubmitButton>
|
||||
<Link href="/admin/pages" className="text-sm text-ink-muted hover:text-ink-strong">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<FormErrorBanner>{state.formError}</FormErrorBanner>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`${ids}-panel-content`}
|
||||
aria-labelledby={`${ids}-tab-content`}
|
||||
hidden={tab !== "content"}
|
||||
>
|
||||
<RichTextEditor
|
||||
name="body"
|
||||
label="Body"
|
||||
initialHTML={page?.body ?? ""}
|
||||
error={err("body")}
|
||||
minHeightClassName="min-h-[max(24rem,calc(100dvh-24rem))]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`${ids}-panel-settings`}
|
||||
aria-labelledby={`${ids}-tab-settings`}
|
||||
hidden={tab !== "settings"}
|
||||
className="max-w-3xl space-y-6"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-title`}>Title</Label>
|
||||
<Input
|
||||
id={`${ids}-title`}
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
if (!slugTouched) setSlug(slugify(e.target.value));
|
||||
}}
|
||||
required
|
||||
aria-invalid={err("title") ? true : undefined}
|
||||
aria-describedby={err("title") ? `${ids}-title-error` : undefined}
|
||||
/>
|
||||
<ErrorText id={`${ids}-title-error`}>{err("title")}</ErrorText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-slug`}>Slug</Label>
|
||||
<Input
|
||||
id={`${ids}-slug`}
|
||||
name="slug"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlug(e.target.value);
|
||||
setSlugTouched(e.target.value !== "");
|
||||
}}
|
||||
aria-invalid={err("slug") ? true : undefined}
|
||||
aria-describedby={`${ids}-slug-help${err("slug") ? ` ${ids}-slug-error` : ""}`}
|
||||
/>
|
||||
<HelpText id={`${ids}-slug-help`}>
|
||||
Public URL: /pages/{slug || "…"} — leave blank to generate from the title.
|
||||
</HelpText>
|
||||
<ErrorText id={`${ids}-slug-error`}>{err("slug")}</ErrorText>
|
||||
</div>
|
||||
|
||||
<HelpText>Published pages can be linked from the top navigation.</HelpText>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
310
src/components/admin/PostForm.tsx
Normal file
310
src/components/admin/PostForm.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useId, useRef, useState } from "react";
|
||||
import { FormErrorBanner } from "@/components/admin/Flash";
|
||||
import { FormTabs } from "@/components/admin/FormTabs";
|
||||
import { RichTextEditor } from "@/components/admin/RichTextEditor";
|
||||
import { SubmitButton } from "@/components/admin/SubmitButton";
|
||||
import { Button, ErrorText, HelpText, Input, Label, Select } from "@/components/ui";
|
||||
import type { Tag } from "@/db/schema";
|
||||
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
|
||||
import type { PostWithTags } from "@/lib/services/posts";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { uploadImageFile } from "@/lib/upload-client";
|
||||
|
||||
type Props = {
|
||||
post?: PostWithTags;
|
||||
allTags: Tag[];
|
||||
defaultAuthor: string;
|
||||
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
||||
};
|
||||
|
||||
// Fields living on the settings panel — used to route validation errors
|
||||
// to the tab the user needs to fix.
|
||||
const SETTINGS_FIELDS = [
|
||||
"title",
|
||||
"slug",
|
||||
"authorName",
|
||||
"featuredImageUrl",
|
||||
"featuredImageAlt",
|
||||
"tagIds",
|
||||
"newTags",
|
||||
];
|
||||
|
||||
/**
|
||||
* WordPress-style layout: the Content tab is a full-height writing canvas;
|
||||
* everything descriptive (title, slug, author, featured image, tags) lives
|
||||
* on the Settings tab. Both panels stay mounted so the single form submits
|
||||
* all fields regardless of the visible tab, and the sticky action bar
|
||||
* keeps status + save reachable from either.
|
||||
*/
|
||||
export function PostForm({ post, allTags, defaultAuthor, action }: Props) {
|
||||
const [state, formAction] = useActionState(action, initialFormState);
|
||||
const ids = useId();
|
||||
|
||||
// The visible tab is derived: an explicit click wins until the next
|
||||
// action result arrives; a result with field errors routes to the tab
|
||||
// that contains them. No effects, no cascading renders.
|
||||
const [tabChoice, setTabChoice] = useState<{
|
||||
tab: "content" | "settings";
|
||||
forState: FormState;
|
||||
}>({ tab: "content", forState: initialFormState });
|
||||
const [title, setTitle] = useState(post?.title ?? "");
|
||||
const [slug, setSlug] = useState(post?.slug ?? "");
|
||||
const [slugTouched, setSlugTouched] = useState(post !== undefined);
|
||||
const [authorName, setAuthorName] = useState(post?.authorName ?? defaultAuthor);
|
||||
const [imageUrl, setImageUrl] = useState(post?.featuredImageUrl ?? "");
|
||||
const [imageAlt, setImageAlt] = useState(post?.featuredImageAlt ?? "");
|
||||
const [status, setStatus] = useState<string>(post?.status ?? "draft");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(
|
||||
() => new Set(post?.tags.map((t) => t.id) ?? []),
|
||||
);
|
||||
const [newTags, setNewTags] = useState("");
|
||||
const [featuredUpload, setFeaturedUpload] = useState<
|
||||
{ kind: "idle" } | { kind: "uploading" } | { kind: "error"; message: string }
|
||||
>({ kind: "idle" });
|
||||
const featuredFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const err = (field: string) => firstFieldError(state, field);
|
||||
const errorKeys = Object.keys(state.fieldErrors ?? {});
|
||||
const settingsHasError = errorKeys.some((key) => SETTINGS_FIELDS.includes(key));
|
||||
const contentHasError = errorKeys.includes("body");
|
||||
|
||||
const tab =
|
||||
tabChoice.forState === state
|
||||
? tabChoice.tab
|
||||
: settingsHasError
|
||||
? "settings"
|
||||
: contentHasError
|
||||
? "content"
|
||||
: tabChoice.tab;
|
||||
const setTab = (next: "content" | "settings") =>
|
||||
setTabChoice({ tab: next, forState: state });
|
||||
|
||||
function toggleTag(id: number) {
|
||||
setSelectedTagIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction}>
|
||||
<div className="sticky top-0 z-20 -mb-px flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-edge bg-background pt-1">
|
||||
<FormTabs
|
||||
idBase={ids}
|
||||
label="Post editor sections"
|
||||
activeId={tab}
|
||||
onSelect={(id) => setTab(id as typeof tab)}
|
||||
tabs={[
|
||||
{ id: "content", label: "Content", hasError: contentHasError },
|
||||
{ id: "settings", label: "Post settings", hasError: settingsHasError },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-3 pb-1.5">
|
||||
<Select
|
||||
aria-label="Status"
|
||||
name="status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-32"
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
</Select>
|
||||
<SubmitButton>Save post</SubmitButton>
|
||||
<Link href="/admin/posts" className="text-sm text-ink-muted hover:text-ink-strong">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-5">
|
||||
<FormErrorBanner>{state.formError}</FormErrorBanner>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`${ids}-panel-content`}
|
||||
aria-labelledby={`${ids}-tab-content`}
|
||||
hidden={tab !== "content"}
|
||||
>
|
||||
<RichTextEditor
|
||||
name="body"
|
||||
label="Body"
|
||||
initialHTML={post?.body ?? ""}
|
||||
error={err("body")}
|
||||
minHeightClassName="min-h-[max(24rem,calc(100dvh-24rem))]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`${ids}-panel-settings`}
|
||||
aria-labelledby={`${ids}-tab-settings`}
|
||||
hidden={tab !== "settings"}
|
||||
className="max-w-3xl space-y-6"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-title`}>Title</Label>
|
||||
<Input
|
||||
id={`${ids}-title`}
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
if (!slugTouched) setSlug(slugify(e.target.value));
|
||||
}}
|
||||
required
|
||||
aria-invalid={err("title") ? true : undefined}
|
||||
aria-describedby={err("title") ? `${ids}-title-error` : undefined}
|
||||
/>
|
||||
<ErrorText id={`${ids}-title-error`}>{err("title")}</ErrorText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-slug`}>Slug</Label>
|
||||
<Input
|
||||
id={`${ids}-slug`}
|
||||
name="slug"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlug(e.target.value);
|
||||
setSlugTouched(e.target.value !== "");
|
||||
}}
|
||||
aria-invalid={err("slug") ? true : undefined}
|
||||
aria-describedby={`${ids}-slug-help${err("slug") ? ` ${ids}-slug-error` : ""}`}
|
||||
/>
|
||||
<HelpText id={`${ids}-slug-help`}>
|
||||
Public URL: /posts/{slug || "…"} — leave blank to generate from the title.
|
||||
</HelpText>
|
||||
<ErrorText id={`${ids}-slug-error`}>{err("slug")}</ErrorText>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-author`}>Author name</Label>
|
||||
<Input
|
||||
id={`${ids}-author`}
|
||||
name="authorName"
|
||||
value={authorName}
|
||||
onChange={(e) => setAuthorName(e.target.value)}
|
||||
required
|
||||
aria-invalid={err("authorName") ? true : undefined}
|
||||
aria-describedby={err("authorName") ? `${ids}-author-error` : undefined}
|
||||
/>
|
||||
<ErrorText id={`${ids}-author-error`}>{err("authorName")}</ErrorText>
|
||||
</div>
|
||||
|
||||
<fieldset className="rounded-lg border border-edge p-4">
|
||||
<legend className="px-1 text-sm font-medium text-ink-strong">Featured image</legend>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-image-url`}>Image URL (optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`${ids}-image-url`}
|
||||
name="featuredImageUrl"
|
||||
placeholder="https://example.com/image.jpg or upload →"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
aria-invalid={err("featuredImageUrl") ? true : undefined}
|
||||
aria-describedby={
|
||||
err("featuredImageUrl") ? `${ids}-image-url-error` : undefined
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
disabled={featuredUpload.kind === "uploading"}
|
||||
onClick={() => featuredFileRef.current?.click()}
|
||||
>
|
||||
{featuredUpload.kind === "uploading" ? "Uploading…" : "Upload"}
|
||||
</Button>
|
||||
<input
|
||||
ref={featuredFileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
|
||||
hidden
|
||||
data-testid="featured-image-input"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
setFeaturedUpload({ kind: "uploading" });
|
||||
const result = await uploadImageFile(file);
|
||||
if ("error" in result) {
|
||||
setFeaturedUpload({ kind: "error", message: result.error });
|
||||
} else {
|
||||
setImageUrl(result.url);
|
||||
setFeaturedUpload({ kind: "idle" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{featuredUpload.kind === "error" && (
|
||||
<ErrorText>{featuredUpload.message}</ErrorText>
|
||||
)}
|
||||
<ErrorText id={`${ids}-image-url-error`}>{err("featuredImageUrl")}</ErrorText>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-image-alt`}>Alt text (optional)</Label>
|
||||
<Input
|
||||
id={`${ids}-image-alt`}
|
||||
name="featuredImageAlt"
|
||||
value={imageAlt}
|
||||
onChange={(e) => setImageAlt(e.target.value)}
|
||||
aria-describedby={`${ids}-image-alt-help`}
|
||||
/>
|
||||
<HelpText id={`${ids}-image-alt-help`}>
|
||||
Describe the image for screen-reader users; leave blank if purely decorative.
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="rounded-lg border border-edge p-4">
|
||||
<legend className="px-1 text-sm font-medium text-ink-strong">Tags</legend>
|
||||
{allTags.length > 0 ? (
|
||||
<ul className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
{allTags.map((tag) => (
|
||||
<li key={tag.id}>
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-ink">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="tagIds"
|
||||
value={tag.id}
|
||||
checked={selectedTagIds.has(tag.id)}
|
||||
onChange={() => toggleTag(tag.id)}
|
||||
className="size-4 accent-(--link)"
|
||||
/>
|
||||
{tag.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-ink-muted">No tags exist yet — create some below.</p>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Label htmlFor={`${ids}-new-tags`}>New tags (optional)</Label>
|
||||
<Input
|
||||
id={`${ids}-new-tags`}
|
||||
name="newTags"
|
||||
value={newTags}
|
||||
onChange={(e) => setNewTags(e.target.value)}
|
||||
placeholder="design, typescript"
|
||||
aria-describedby={`${ids}-new-tags-help`}
|
||||
/>
|
||||
<HelpText id={`${ids}-new-tags-help`}>
|
||||
Comma-separated. Created and attached to this post on save.
|
||||
</HelpText>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
375
src/components/admin/RichTextEditor.tsx
Normal file
375
src/components/admin/RichTextEditor.tsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
"use client";
|
||||
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { ErrorText, Label, cx } from "@/components/ui";
|
||||
import { renderMarkdown } from "@/lib/markdown";
|
||||
import { uploadImageFile } from "@/lib/upload-client";
|
||||
|
||||
/**
|
||||
* WordPress-style WYSIWYG editor (Tiptap/ProseMirror).
|
||||
*
|
||||
* - Emits HTML into a hidden field so the surrounding server-action form
|
||||
* submits it like any other input (sanitized server-side on save).
|
||||
* - Images are uploaded via /api/admin/uploads — from the toolbar button,
|
||||
* by dropping files onto the editor, or by pasting from the clipboard —
|
||||
* and inserted inline as /uploads/... URLs.
|
||||
* - Pasting plain text that looks like Markdown converts it through the
|
||||
* same remark pipeline used everywhere else; pasting rich HTML uses
|
||||
* ProseMirror's native handling.
|
||||
*/
|
||||
|
||||
// Cheap markdown sniff: headings, lists, quotes, fences, emphasis,
|
||||
// links, or inline code. Plain prose without these pastes untouched.
|
||||
const MARKDOWN_PATTERN =
|
||||
/(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s?|```)|\*\*[^*\n]+\*\*|__[^_\n]+__|\[[^\]\n]+\]\([^)\n]+\)|`[^`\n]+`/;
|
||||
|
||||
function looksLikeMarkdown(text: string): boolean {
|
||||
return MARKDOWN_PATTERN.test(text);
|
||||
}
|
||||
|
||||
function ToolButton({
|
||||
label,
|
||||
active = false,
|
||||
disabled = false,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
// Keep the editor focused while clicking toolbar buttons; without
|
||||
// this the button grabs focus on mousedown and typed characters go
|
||||
// to the button instead of the document.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
"inline-flex h-8 min-w-8 items-center justify-center rounded px-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40",
|
||||
active
|
||||
? "bg-link text-ink-inverse"
|
||||
: "text-ink hover:bg-background hover:text-ink-strong",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDivider() {
|
||||
return <span aria-hidden="true" className="mx-1 h-5 w-px self-center bg-edge-strong" />;
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
name,
|
||||
label,
|
||||
initialHTML,
|
||||
error,
|
||||
minHeightClassName = "min-h-72",
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
initialHTML: string;
|
||||
error?: string;
|
||||
/** Tailwind min-height class for the writing canvas. */
|
||||
minHeightClassName?: string;
|
||||
}) {
|
||||
const id = useId();
|
||||
const errorId = `${id}-error`;
|
||||
const [html, setHtml] = useState(initialHTML);
|
||||
const [uploadState, setUploadState] = useState<
|
||||
{ kind: "idle" } | { kind: "uploading" } | { kind: "error"; message: string }
|
||||
>({ kind: "idle" });
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
async function uploadAndInsert(files: File[]) {
|
||||
const editor = editorRef.current;
|
||||
const images = files.filter((f) => f.type.startsWith("image/"));
|
||||
if (!editor || images.length === 0) return;
|
||||
setUploadState({ kind: "uploading" });
|
||||
for (const file of images) {
|
||||
const result = await uploadImageFile(file);
|
||||
if ("error" in result) {
|
||||
setUploadState({ kind: "error", message: result.error });
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().setImage({ src: result.url, alt: "" }).run();
|
||||
}
|
||||
setUploadState({ kind: "idle" });
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3, 4] },
|
||||
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
|
||||
}),
|
||||
Image.configure({ allowBase64: false }),
|
||||
TableKit.configure({ table: { resizable: false } }),
|
||||
Placeholder.configure({ placeholder: "Write your story…" }),
|
||||
],
|
||||
content: initialHTML,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: `markdown-body ${minHeightClassName} px-4 py-3 focus:outline-none`,
|
||||
"aria-label": label,
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard) return false;
|
||||
|
||||
const files = Array.from(clipboard.files ?? []);
|
||||
if (files.some((f) => f.type.startsWith("image/"))) {
|
||||
event.preventDefault();
|
||||
void uploadAndInsert(files);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rich HTML pastes keep ProseMirror's native handling.
|
||||
if (clipboard.getData("text/html")) return false;
|
||||
|
||||
const text = clipboard.getData("text/plain");
|
||||
if (text && looksLikeMarkdown(text)) {
|
||||
event.preventDefault();
|
||||
editorRef.current?.chain().focus().insertContent(renderMarkdown(text)).run();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (_view, event) => {
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.some((f) => f.type.startsWith("image/"))) {
|
||||
event.preventDefault();
|
||||
void uploadAndInsert(files);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
setHtml(editor.isEmpty ? "" : editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
// The paste/drop handlers above are created once by useEditor and reach
|
||||
// the editor through this ref; render-time assignment would trip the
|
||||
// react-hooks/refs rule, so sync it in an effect instead.
|
||||
useEffect(() => {
|
||||
editorRef.current = editor;
|
||||
}, [editor]);
|
||||
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) =>
|
||||
editor
|
||||
? {
|
||||
paragraph: editor.isActive("paragraph"),
|
||||
h2: editor.isActive("heading", { level: 2 }),
|
||||
h3: editor.isActive("heading", { level: 3 }),
|
||||
h4: editor.isActive("heading", { level: 4 }),
|
||||
bold: editor.isActive("bold"),
|
||||
italic: editor.isActive("italic"),
|
||||
underline: editor.isActive("underline"),
|
||||
strike: editor.isActive("strike"),
|
||||
code: editor.isActive("code"),
|
||||
link: editor.isActive("link"),
|
||||
bulletList: editor.isActive("bulletList"),
|
||||
orderedList: editor.isActive("orderedList"),
|
||||
blockquote: editor.isActive("blockquote"),
|
||||
codeBlock: editor.isActive("codeBlock"),
|
||||
image: editor.isActive("image"),
|
||||
table: editor.isActive("table"),
|
||||
canUndo: editor.can().undo(),
|
||||
canRedo: editor.can().redo(),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
function setLink() {
|
||||
if (!editor) return;
|
||||
const current = editor.getAttributes("link").href as string | undefined;
|
||||
const url = window.prompt("Link URL (leave empty to remove):", current ?? "");
|
||||
if (url === null) return;
|
||||
if (url === "") {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
} else {
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
}
|
||||
}
|
||||
|
||||
function editImageAlt() {
|
||||
if (!editor) return;
|
||||
const current = (editor.getAttributes("image").alt as string | undefined) ?? "";
|
||||
const alt = window.prompt(
|
||||
"Alt text for this image (leave empty if decorative):",
|
||||
current,
|
||||
);
|
||||
if (alt === null) return;
|
||||
editor.chain().focus().updateAttributes("image", { alt }).run();
|
||||
}
|
||||
|
||||
const chain = () => editor!.chain().focus();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="mb-1.5">{label}</Label>
|
||||
<input type="hidden" name={name} value={html} />
|
||||
<div
|
||||
className={cx(
|
||||
"editor-shell rounded-md border bg-background",
|
||||
error ? "border-danger" : "border-edge",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={`${label} formatting`}
|
||||
className="flex flex-wrap items-center gap-0.5 border-b border-edge bg-surface px-2 py-1.5"
|
||||
>
|
||||
<ToolButton label="Paragraph" active={state?.paragraph} disabled={!editor} onClick={() => chain().setParagraph().run()}>
|
||||
¶
|
||||
</ToolButton>
|
||||
<ToolButton label="Heading level 2" active={state?.h2} disabled={!editor} onClick={() => chain().toggleHeading({ level: 2 }).run()}>
|
||||
H2
|
||||
</ToolButton>
|
||||
<ToolButton label="Heading level 3" active={state?.h3} disabled={!editor} onClick={() => chain().toggleHeading({ level: 3 }).run()}>
|
||||
H3
|
||||
</ToolButton>
|
||||
<ToolButton label="Heading level 4" active={state?.h4} disabled={!editor} onClick={() => chain().toggleHeading({ level: 4 }).run()}>
|
||||
H4
|
||||
</ToolButton>
|
||||
<ToolDivider />
|
||||
<ToolButton label="Bold" active={state?.bold} disabled={!editor} onClick={() => chain().toggleBold().run()} className="font-bold">
|
||||
B
|
||||
</ToolButton>
|
||||
<ToolButton label="Italic" active={state?.italic} disabled={!editor} onClick={() => chain().toggleItalic().run()} className="italic">
|
||||
I
|
||||
</ToolButton>
|
||||
<ToolButton label="Underline" active={state?.underline} disabled={!editor} onClick={() => chain().toggleUnderline().run()} className="underline">
|
||||
U
|
||||
</ToolButton>
|
||||
<ToolButton label="Strikethrough" active={state?.strike} disabled={!editor} onClick={() => chain().toggleStrike().run()} className="line-through">
|
||||
S
|
||||
</ToolButton>
|
||||
<ToolButton label="Inline code" active={state?.code} disabled={!editor} onClick={() => chain().toggleCode().run()} className="font-mono text-xs">
|
||||
{"</>"}
|
||||
</ToolButton>
|
||||
<ToolButton label="Link" active={state?.link} disabled={!editor} onClick={setLink}>
|
||||
🔗
|
||||
</ToolButton>
|
||||
<ToolDivider />
|
||||
<ToolButton label="Bullet list" active={state?.bulletList} disabled={!editor} onClick={() => chain().toggleBulletList().run()}>
|
||||
••
|
||||
</ToolButton>
|
||||
<ToolButton label="Numbered list" active={state?.orderedList} disabled={!editor} onClick={() => chain().toggleOrderedList().run()}>
|
||||
1.
|
||||
</ToolButton>
|
||||
<ToolButton label="Blockquote" active={state?.blockquote} disabled={!editor} onClick={() => chain().toggleBlockquote().run()}>
|
||||
❝
|
||||
</ToolButton>
|
||||
<ToolButton label="Code block" active={state?.codeBlock} disabled={!editor} onClick={() => chain().toggleCodeBlock().run()} className="font-mono text-xs">
|
||||
{"{ }"}
|
||||
</ToolButton>
|
||||
<ToolButton label="Horizontal rule" disabled={!editor} onClick={() => chain().setHorizontalRule().run()}>
|
||||
—
|
||||
</ToolButton>
|
||||
<ToolDivider />
|
||||
<ToolButton label="Upload and insert image" disabled={!editor} onClick={() => fileInputRef.current?.click()}>
|
||||
🖼
|
||||
</ToolButton>
|
||||
{state?.image && (
|
||||
<ToolButton label="Edit image alt text" disabled={!editor} onClick={editImageAlt}>
|
||||
Alt
|
||||
</ToolButton>
|
||||
)}
|
||||
<ToolButton
|
||||
label="Insert table"
|
||||
active={state?.table}
|
||||
disabled={!editor}
|
||||
onClick={() => chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
||||
>
|
||||
⊞
|
||||
</ToolButton>
|
||||
<ToolDivider />
|
||||
<ToolButton label="Undo" disabled={!editor || !state?.canUndo} onClick={() => chain().undo().run()}>
|
||||
↺
|
||||
</ToolButton>
|
||||
<ToolButton label="Redo" disabled={!editor || !state?.canRedo} onClick={() => chain().redo().run()}>
|
||||
↻
|
||||
</ToolButton>
|
||||
</div>
|
||||
|
||||
{state?.table && (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Table editing"
|
||||
className="flex flex-wrap items-center gap-0.5 border-b border-edge bg-surface px-2 py-1"
|
||||
>
|
||||
<ToolButton label="Add column after" disabled={!editor} onClick={() => chain().addColumnAfter().run()}>
|
||||
+Col
|
||||
</ToolButton>
|
||||
<ToolButton label="Delete column" disabled={!editor} onClick={() => chain().deleteColumn().run()}>
|
||||
−Col
|
||||
</ToolButton>
|
||||
<ToolButton label="Add row after" disabled={!editor} onClick={() => chain().addRowAfter().run()}>
|
||||
+Row
|
||||
</ToolButton>
|
||||
<ToolButton label="Delete row" disabled={!editor} onClick={() => chain().deleteRow().run()}>
|
||||
−Row
|
||||
</ToolButton>
|
||||
<ToolButton label="Toggle header row" disabled={!editor} onClick={() => chain().toggleHeaderRow().run()}>
|
||||
Header
|
||||
</ToolButton>
|
||||
<ToolButton label="Delete table" disabled={!editor} onClick={() => chain().deleteTable().run()}>
|
||||
✕ Table
|
||||
</ToolButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="editor-image-input"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
void uploadAndInsert(files);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 text-xs text-ink-muted" role="status" aria-live="polite">
|
||||
{uploadState.kind === "uploading" && "Uploading image…"}
|
||||
{uploadState.kind === "error" && (
|
||||
<span className="text-danger">{uploadState.message}</span>
|
||||
)}
|
||||
{uploadState.kind === "idle" &&
|
||||
"Drop or paste images to upload them. Pasted Markdown is converted automatically."}
|
||||
</p>
|
||||
<ErrorText id={errorId}>{error}</ErrorText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
447
src/components/admin/SettingsForm.tsx
Normal file
447
src/components/admin/SettingsForm.tsx
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
"use client";
|
||||
|
||||
import { useActionState, useId, useState } from "react";
|
||||
import { Flash, FormErrorBanner } from "@/components/admin/Flash";
|
||||
import { SubmitButton } from "@/components/admin/SubmitButton";
|
||||
import { Button, ErrorText, HelpText, Input, Label, Select } from "@/components/ui";
|
||||
import type { NavItem, Page, Settings, Tag } from "@/db/schema";
|
||||
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
|
||||
import { FONT_META, FONT_ORDER, THEME_META, THEME_ORDER } from "@/lib/themes";
|
||||
|
||||
type NavRow = {
|
||||
key: number;
|
||||
label: string;
|
||||
kind: "page" | "url";
|
||||
url: string;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
settings: Settings;
|
||||
navItems: NavItem[];
|
||||
allTags: Tag[];
|
||||
publishedPages: Page[];
|
||||
action: (prev: FormState, formData: FormData) => Promise<FormState>;
|
||||
};
|
||||
|
||||
let nextKey = 1;
|
||||
|
||||
export function SettingsForm({ settings, navItems, allTags, publishedPages, action }: Props) {
|
||||
const [state, formAction] = useActionState(action, initialFormState);
|
||||
const ids = useId();
|
||||
|
||||
const [siteTitle, setSiteTitle] = useState(settings.siteTitle);
|
||||
const [headerText, setHeaderText] = useState(settings.headerText);
|
||||
const [footerText, setFooterText] = useState(settings.footerText);
|
||||
const [postsPerPage, setPostsPerPage] = useState(String(settings.postsPerPage));
|
||||
const [excerptWords, setExcerptWords] = useState(String(settings.excerptWords));
|
||||
const [homeMode, setHomeMode] = useState<string>(settings.homeMode);
|
||||
const [homeTagId, setHomeTagId] = useState(settings.homeTagId?.toString() ?? "");
|
||||
const [homePageId, setHomePageId] = useState(settings.homePageId?.toString() ?? "");
|
||||
const [theme, setTheme] = useState<string>(settings.theme);
|
||||
const [font, setFont] = useState<string>(settings.font);
|
||||
const [navRows, setNavRows] = useState<NavRow[]>(() =>
|
||||
navItems.map((item) => ({
|
||||
key: nextKey++,
|
||||
label: item.label,
|
||||
kind: item.url !== null ? "url" : "page",
|
||||
url: item.url ?? "",
|
||||
pageId: item.pageId?.toString() ?? "",
|
||||
})),
|
||||
);
|
||||
|
||||
const err = (field: string) => firstFieldError(state, field);
|
||||
|
||||
const navItemsJson = JSON.stringify(
|
||||
navRows.map((row) => ({
|
||||
label: row.label,
|
||||
url: row.kind === "url" ? row.url : null,
|
||||
pageId: row.kind === "page" && row.pageId !== "" ? Number(row.pageId) : null,
|
||||
})),
|
||||
);
|
||||
|
||||
function updateRow(key: number, patch: Partial<NavRow>) {
|
||||
setNavRows((rows) => rows.map((row) => (row.key === key ? { ...row, ...patch } : row)));
|
||||
}
|
||||
|
||||
function moveRow(index: number, delta: -1 | 1) {
|
||||
setNavRows((rows) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= rows.length) return rows;
|
||||
const next = [...rows];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="max-w-3xl space-y-8">
|
||||
{state.status === "success" && <Flash>Settings saved.</Flash>}
|
||||
<FormErrorBanner>{state.formError}</FormErrorBanner>
|
||||
|
||||
<section aria-labelledby={`${ids}-general`} className="space-y-5">
|
||||
<h2 id={`${ids}-general`} className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong">
|
||||
General
|
||||
</h2>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-site-title`}>Site title</Label>
|
||||
<Input
|
||||
id={`${ids}-site-title`}
|
||||
name="siteTitle"
|
||||
value={siteTitle}
|
||||
onChange={(e) => setSiteTitle(e.target.value)}
|
||||
required
|
||||
aria-invalid={err("siteTitle") ? true : undefined}
|
||||
/>
|
||||
<ErrorText>{err("siteTitle")}</ErrorText>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-header-text`}>Header text</Label>
|
||||
<Input
|
||||
id={`${ids}-header-text`}
|
||||
name="headerText"
|
||||
value={headerText}
|
||||
onChange={(e) => setHeaderText(e.target.value)}
|
||||
/>
|
||||
<HelpText>Shown as the tagline under the site title.</HelpText>
|
||||
<ErrorText>{err("headerText")}</ErrorText>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-footer-text`}>Footer text</Label>
|
||||
<Input
|
||||
id={`${ids}-footer-text`}
|
||||
name="footerText"
|
||||
value={footerText}
|
||||
onChange={(e) => setFooterText(e.target.value)}
|
||||
/>
|
||||
<ErrorText>{err("footerText")}</ErrorText>
|
||||
</div>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-per-page`}>Posts per page</Label>
|
||||
<Input
|
||||
id={`${ids}-per-page`}
|
||||
name="postsPerPage"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={postsPerPage}
|
||||
onChange={(e) => setPostsPerPage(e.target.value)}
|
||||
required
|
||||
aria-invalid={err("postsPerPage") ? true : undefined}
|
||||
/>
|
||||
<ErrorText>{err("postsPerPage")}</ErrorText>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-excerpt-words`}>Excerpt word limit</Label>
|
||||
<Input
|
||||
id={`${ids}-excerpt-words`}
|
||||
name="excerptWords"
|
||||
type="number"
|
||||
min={5}
|
||||
max={200}
|
||||
value={excerptWords}
|
||||
onChange={(e) => setExcerptWords(e.target.value)}
|
||||
required
|
||||
aria-invalid={err("excerptWords") ? true : undefined}
|
||||
/>
|
||||
<ErrorText>{err("excerptWords")}</ErrorText>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby={`${ids}-appearance`} className="space-y-4">
|
||||
<h2
|
||||
id={`${ids}-appearance`}
|
||||
className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong"
|
||||
>
|
||||
Appearance
|
||||
</h2>
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium text-ink-strong">Theme</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{THEME_ORDER.map((value) => {
|
||||
const meta = THEME_META[value];
|
||||
return (
|
||||
<label
|
||||
key={value}
|
||||
className={`flex cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 text-sm transition-colors ${
|
||||
theme === value
|
||||
? "border-link bg-background text-ink-strong"
|
||||
: "border-edge text-ink hover:border-edge-strong"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={value}
|
||||
checked={theme === value}
|
||||
onChange={() => setTheme(value)}
|
||||
className="size-4 shrink-0 accent-(--link)"
|
||||
/>
|
||||
{/* Miniature palette swatch */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-6 w-10 shrink-0 items-center justify-center gap-1 rounded border border-edge-strong"
|
||||
style={{ background: meta.bg }}
|
||||
>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ background: meta.fg }} />
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: meta.accent }}
|
||||
/>
|
||||
</span>
|
||||
{meta.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<HelpText>Applies to the public site and the admin area after saving.</HelpText>
|
||||
<ErrorText>{err("theme")}</ErrorText>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium text-ink-strong">Font</legend>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{FONT_ORDER.map((value) => {
|
||||
const meta = FONT_META[value];
|
||||
return (
|
||||
<label
|
||||
key={value}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
font === value
|
||||
? "border-link bg-background"
|
||||
: "border-edge hover:border-edge-strong"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="font"
|
||||
value={value}
|
||||
checked={font === value}
|
||||
onChange={() => setFont(value)}
|
||||
className="mt-1 size-4 shrink-0 accent-(--link)"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-ink-strong">
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-ink-muted">
|
||||
{meta.description}
|
||||
</span>
|
||||
{/* Live sample in the actual font (loaded on demand). */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-1.5 block truncate text-lg leading-snug text-ink"
|
||||
style={{ fontFamily: `var(${meta.cssVar})` }}
|
||||
>
|
||||
Grumpy wizards make toxic brew. 0123
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<HelpText>Body font for the whole site; code blocks always use Geist Mono.</HelpText>
|
||||
<ErrorText>{err("font")}</ErrorText>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby={`${ids}-home`} className="space-y-4">
|
||||
<h2 id={`${ids}-home`} className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong">
|
||||
Home page
|
||||
</h2>
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium text-ink-strong">
|
||||
What should the front page show?
|
||||
</legend>
|
||||
<div className="space-y-2 text-sm">
|
||||
{(
|
||||
[
|
||||
["posts", "All published posts"],
|
||||
["tag", "Posts with a selected tag"],
|
||||
["page", "A selected static page"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<label key={value} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="homeMode"
|
||||
value={value}
|
||||
checked={homeMode === value}
|
||||
onChange={() => setHomeMode(value)}
|
||||
className="size-4 accent-(--link)"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{homeMode === "tag" && (
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-home-tag`}>Tag to feature</Label>
|
||||
<Select
|
||||
id={`${ids}-home-tag`}
|
||||
name="homeTagId"
|
||||
value={homeTagId}
|
||||
onChange={(e) => setHomeTagId(e.target.value)}
|
||||
className="max-w-72"
|
||||
aria-invalid={err("homeTagId") ? true : undefined}
|
||||
>
|
||||
<option value="">Choose a tag…</option>
|
||||
{allTags.map((tag) => (
|
||||
<option key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<ErrorText>{err("homeTagId")}</ErrorText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{homeMode === "page" && (
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-home-page`}>Page to show</Label>
|
||||
<Select
|
||||
id={`${ids}-home-page`}
|
||||
name="homePageId"
|
||||
value={homePageId}
|
||||
onChange={(e) => setHomePageId(e.target.value)}
|
||||
className="max-w-72"
|
||||
aria-invalid={err("homePageId") ? true : undefined}
|
||||
>
|
||||
<option value="">Choose a page…</option>
|
||||
{publishedPages.map((page) => (
|
||||
<option key={page.id} value={page.id}>
|
||||
{page.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<HelpText>Only published pages are listed.</HelpText>
|
||||
<ErrorText>{err("homePageId")}</ErrorText>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby={`${ids}-nav`} className="space-y-4">
|
||||
<h2 id={`${ids}-nav`} className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong">
|
||||
Top navigation
|
||||
</h2>
|
||||
<p className="text-sm text-ink-muted">
|
||||
Items link to a published page or to an internal (<code>/path</code>) or external
|
||||
(<code>https://…</code>) URL. Items pointing at unpublished pages are hidden until
|
||||
the page is published again.
|
||||
</p>
|
||||
|
||||
{navRows.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-edge-strong px-4 py-3 text-sm text-ink-muted">
|
||||
No navigation items — the top navigation is hidden.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-3">
|
||||
{navRows.map((row, index) => (
|
||||
<li key={row.key} className="rounded-lg border border-edge bg-surface p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-nav-label-${row.key}`}>Label</Label>
|
||||
<Input
|
||||
id={`${ids}-nav-label-${row.key}`}
|
||||
value={row.label}
|
||||
onChange={(e) => updateRow(row.key, { label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${ids}-nav-kind-${row.key}`}>Links to</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
id={`${ids}-nav-kind-${row.key}`}
|
||||
value={row.kind}
|
||||
onChange={(e) =>
|
||||
updateRow(row.key, { kind: e.target.value as NavRow["kind"] })
|
||||
}
|
||||
className="w-28 shrink-0"
|
||||
>
|
||||
<option value="page">Page</option>
|
||||
<option value="url">URL</option>
|
||||
</Select>
|
||||
{row.kind === "page" ? (
|
||||
<Select
|
||||
aria-label="Page"
|
||||
value={row.pageId}
|
||||
onChange={(e) => updateRow(row.key, { pageId: e.target.value })}
|
||||
>
|
||||
<option value="">Choose a page…</option>
|
||||
{publishedPages.map((page) => (
|
||||
<option key={page.id} value={page.id}>
|
||||
{page.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
aria-label="URL"
|
||||
placeholder="/posts or https://example.com"
|
||||
value={row.url}
|
||||
onChange={(e) => updateRow(row.key, { url: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label={`Move “${row.label || "item"}” up`}
|
||||
disabled={index === 0}
|
||||
onClick={() => moveRow(index, -1)}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label={`Move “${row.label || "item"}” down`}
|
||||
disabled={index === navRows.length - 1}
|
||||
onClick={() => moveRow(index, 1)}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
className="px-2.5 py-1 text-xs"
|
||||
onClick={() => setNavRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
setNavRows((rows) => [
|
||||
...rows,
|
||||
{ key: nextKey++, label: "", kind: "url", url: "", pageId: "" },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add navigation item
|
||||
</Button>
|
||||
<input type="hidden" name="navItemsJson" value={navItemsJson} />
|
||||
</section>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-edge pt-6">
|
||||
<SubmitButton>Save settings</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
16
src/components/admin/StatusBadge.tsx
Normal file
16
src/components/admin/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { ContentStatus } from "@/db/schema";
|
||||
|
||||
export function StatusBadge({ status }: { status: ContentStatus }) {
|
||||
if (status === "published") {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-success/40 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
|
||||
Published
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full border border-warning/40 bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
Draft
|
||||
</span>
|
||||
);
|
||||
}
|
||||
30
src/components/admin/SubmitButton.tsx
Normal file
30
src/components/admin/SubmitButton.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { Button, type ButtonVariant } from "@/components/ui";
|
||||
|
||||
/** Submit button with a pending state; must be rendered inside the form. */
|
||||
export function SubmitButton({
|
||||
children,
|
||||
pendingText = "Saving…",
|
||||
variant = "primary",
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
pendingText?: string;
|
||||
variant?: ButtonVariant;
|
||||
className?: string;
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
variant={variant}
|
||||
className={className}
|
||||
disabled={pending}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{pending ? pendingText : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
13
src/components/public/ContentBody.tsx
Normal file
13
src/components/public/ContentBody.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { sanitizeHtml } from "@/lib/html";
|
||||
|
||||
/**
|
||||
* Server component rendering a stored post/page body. Bodies are HTML
|
||||
* produced by the admin editor (or converted from Markdown at seed time)
|
||||
* and are sanitized both on save and — here — on render, so the injected
|
||||
* HTML can never contain scripts, event handlers, or javascript: URLs.
|
||||
*/
|
||||
export function ContentBody({ html }: { html: string }) {
|
||||
return (
|
||||
<div className="markdown-body" dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }} />
|
||||
);
|
||||
}
|
||||
7
src/components/public/EmptyState.tsx
Normal file
7
src/components/public/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-edge-strong bg-surface/50 px-6 py-14 text-center text-ink-muted">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/public/FeaturedImage.tsx
Normal file
44
src/components/public/FeaturedImage.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Featured images are arbitrary admin-supplied remote URLs, so next/image
|
||||
* is not usable without an open remotePatterns wildcard (which re-exposes
|
||||
* the optimizer as a proxy). A plain <img> with lazy loading plus an
|
||||
* explicit error fallback is the deliberate MVP trade-off — see README.
|
||||
*/
|
||||
export function FeaturedImage({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
{...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
|
||||
className={`flex items-center justify-center bg-background text-xs text-ink-muted ${className ?? ""}`}
|
||||
>
|
||||
<span aria-hidden="true">image unavailable</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- see comment above
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
128
src/components/public/MobileMenu.tsx
Normal file
128
src/components/public/MobileMenu.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PublicTag } from "@/lib/services/tags";
|
||||
import type { NavLink } from "@/lib/services/settings";
|
||||
|
||||
const itemClasses =
|
||||
"block rounded-md px-3 py-2 text-base font-medium text-ink transition-colors hover:bg-surface hover:text-ink-strong";
|
||||
|
||||
/**
|
||||
* Mobile replacement for the desktop sidebar + top navigation: a full-screen
|
||||
* overlay listing the nav items, the All Posts link, and every public tag.
|
||||
*/
|
||||
export function MobileMenu({ nav, tags }: { nav: NavLink[]; tags: PublicTag[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<div className="lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-edge px-3 py-2 text-sm font-medium text-ink transition-colors hover:border-edge-strong hover:text-ink-strong"
|
||||
>
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M2 4h12M2 8h12M2 12h12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Menu
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
id="mobile-menu"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Site menu"
|
||||
className="fixed inset-0 z-50 overflow-y-auto bg-background"
|
||||
>
|
||||
<div className="container-site py-4">
|
||||
<div className="flex items-center justify-between border-b border-edge pb-4">
|
||||
<p className="text-lg font-semibold text-ink-strong">Menu</p>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
onClick={close}
|
||||
className="rounded-md border border-edge px-3 py-2 text-sm font-medium text-ink transition-colors hover:border-edge-strong hover:text-ink-strong"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{nav.length > 0 && (
|
||||
<nav aria-label="Main navigation" className="mt-4">
|
||||
<ul className="space-y-1">
|
||||
{nav.map((link) => (
|
||||
<li key={`${link.href}-${link.label}`}>
|
||||
{link.external ? (
|
||||
<a href={link.href} rel="noopener noreferrer" className={itemClasses}>
|
||||
{link.label}
|
||||
<span aria-hidden="true"> ↗</span>
|
||||
<span className="sr-only"> (external link)</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link href={link.href} onClick={close} className={itemClasses}>
|
||||
{link.label}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<nav aria-label="Posts by tag" className="mt-6 border-t border-edge pt-4">
|
||||
<p className="px-3 text-xs font-semibold uppercase tracking-wider text-ink-muted">
|
||||
Browse
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>
|
||||
<Link href="/posts" onClick={close} className={itemClasses}>
|
||||
All posts
|
||||
</Link>
|
||||
</li>
|
||||
{tags.map((tag) => (
|
||||
<li key={tag.id}>
|
||||
<Link
|
||||
href={`/tags/${tag.slug}`}
|
||||
onClick={close}
|
||||
className={`${itemClasses} flex items-center justify-between gap-2`}
|
||||
>
|
||||
<span>{tag.name}</span>
|
||||
<span aria-hidden="true" className="text-sm text-ink-muted">
|
||||
{tag.postCount}
|
||||
</span>
|
||||
<span className="sr-only">({tag.postCount} posts)</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/components/public/NavLinkItem.tsx
Normal file
22
src/components/public/NavLinkItem.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import Link from "next/link";
|
||||
import type { NavLink } from "@/lib/services/settings";
|
||||
|
||||
const classes =
|
||||
"block rounded-md px-3 py-2 text-sm font-medium text-ink transition-colors hover:bg-background hover:text-ink-strong";
|
||||
|
||||
export function NavLinkItem({ link, className }: { link: NavLink; className?: string }) {
|
||||
if (link.external) {
|
||||
return (
|
||||
<a href={link.href} rel="noopener noreferrer" className={className ?? classes}>
|
||||
{link.label}
|
||||
<span aria-hidden="true"> ↗</span>
|
||||
<span className="sr-only"> (external link)</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={link.href} className={className ?? classes}>
|
||||
{link.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
16
src/components/public/PageArticle.tsx
Normal file
16
src/components/public/PageArticle.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Page } from "@/db/schema";
|
||||
import { ContentBody } from "./ContentBody";
|
||||
|
||||
/** Static-page rendering, shared by /pages/[slug], the home page, and previews. */
|
||||
export function PageArticle({ page }: { page: Page }) {
|
||||
return (
|
||||
<article className="mx-auto max-w-[46rem]">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-ink-bright sm:text-4xl">
|
||||
{page.title}
|
||||
</h1>
|
||||
<div className="mt-8">
|
||||
<ContentBody html={page.body} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
45
src/components/public/PaginationNav.tsx
Normal file
45
src/components/public/PaginationNav.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import Link from "next/link";
|
||||
|
||||
const linkClasses =
|
||||
"inline-flex items-center gap-1 rounded-md border border-edge bg-surface px-3 py-1.5 font-medium text-ink transition-colors hover:border-link hover:text-link";
|
||||
const disabledClasses =
|
||||
"inline-flex items-center gap-1 rounded-md border border-edge px-3 py-1.5 text-ink-muted opacity-50";
|
||||
|
||||
export function PaginationNav({
|
||||
page,
|
||||
pageCount,
|
||||
basePath,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
basePath: string;
|
||||
}) {
|
||||
if (pageCount <= 1) return null;
|
||||
const href = (p: number) => (p <= 1 ? basePath : `${basePath}?page=${p}`);
|
||||
|
||||
return (
|
||||
<nav aria-label="Pagination" className="mt-10 flex items-center justify-between gap-4 text-sm">
|
||||
{page > 1 ? (
|
||||
<Link rel="prev" href={href(page - 1)} className={linkClasses}>
|
||||
<span aria-hidden="true">←</span> Newer
|
||||
</Link>
|
||||
) : (
|
||||
<span className={disabledClasses} aria-hidden="true">
|
||||
<span>←</span> Newer
|
||||
</span>
|
||||
)}
|
||||
<span className="text-ink-muted">
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
{page < pageCount ? (
|
||||
<Link rel="next" href={href(page + 1)} className={linkClasses}>
|
||||
Older <span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className={disabledClasses} aria-hidden="true">
|
||||
Older <span>→</span>
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
41
src/components/public/PostArticle.tsx
Normal file
41
src/components/public/PostArticle.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { formatDate, isoDate } from "@/lib/format";
|
||||
import type { PostWithTags } from "@/lib/services/posts";
|
||||
import { ContentBody } from "./ContentBody";
|
||||
import { FeaturedImage } from "./FeaturedImage";
|
||||
import { TagChips } from "./TagChips";
|
||||
|
||||
/** Full post rendering, shared by the public route and the admin preview. */
|
||||
export function PostArticle({ post }: { post: PostWithTags }) {
|
||||
return (
|
||||
<article className="mx-auto max-w-[46rem]">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-ink-bright sm:text-4xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-ink-muted">
|
||||
<time dateTime={isoDate(post.publishedAt)}>{formatDate(post.publishedAt)}</time>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>by {post.authorName}</span>
|
||||
</p>
|
||||
</header>
|
||||
{post.featuredImageUrl && (
|
||||
<figure className="mt-8 overflow-hidden rounded-lg border border-edge bg-surface">
|
||||
<FeaturedImage
|
||||
src={post.featuredImageUrl}
|
||||
alt={post.featuredImageAlt ?? ""}
|
||||
className="max-h-[28rem] w-full object-cover"
|
||||
/>
|
||||
</figure>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<ContentBody html={post.body} />
|
||||
</div>
|
||||
{post.tags.length > 0 && (
|
||||
<footer className="mt-12 border-t border-edge pt-6">
|
||||
<h2 className="sr-only">Tagged with</h2>
|
||||
<TagChips tags={post.tags} />
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
53
src/components/public/PostCard.tsx
Normal file
53
src/components/public/PostCard.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import Link from "next/link";
|
||||
import { generateExcerpt } from "@/lib/excerpt";
|
||||
import { formatDate, isoDate } from "@/lib/format";
|
||||
import type { PostWithTags } from "@/lib/services/posts";
|
||||
import { FeaturedImage } from "./FeaturedImage";
|
||||
import { TagChips } from "./TagChips";
|
||||
|
||||
export function PostCard({
|
||||
post,
|
||||
excerptWords,
|
||||
}: {
|
||||
post: PostWithTags;
|
||||
excerptWords: number;
|
||||
}) {
|
||||
const excerpt = generateExcerpt(post.body, excerptWords);
|
||||
return (
|
||||
<article className="overflow-hidden rounded-lg border border-edge bg-surface transition-colors hover:border-edge-strong">
|
||||
{post.featuredImageUrl && (
|
||||
// The image duplicates the title link, so it is hidden from the
|
||||
// accessibility tree and skipped in the tab order.
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="block border-b border-edge"
|
||||
>
|
||||
<FeaturedImage
|
||||
src={post.featuredImageUrl}
|
||||
alt=""
|
||||
className="aspect-[2/1] w-full object-cover"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<div className="p-5 sm:p-6">
|
||||
<h2 className="text-xl font-semibold leading-snug">
|
||||
<Link
|
||||
href={`/posts/${post.slug}`}
|
||||
className="text-ink-strong transition-colors hover:text-link"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="mt-1.5 text-sm text-ink-muted">
|
||||
<time dateTime={isoDate(post.publishedAt)}>{formatDate(post.publishedAt)}</time>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>by {post.authorName}</span>
|
||||
</p>
|
||||
{excerpt && <p className="mt-3 leading-relaxed">{excerpt}</p>}
|
||||
<TagChips tags={post.tags} className="mt-4" />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
41
src/components/public/PostListSection.tsx
Normal file
41
src/components/public/PostListSection.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import { listPublishedPosts } from "@/lib/services/posts";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { PaginationNav } from "./PaginationNav";
|
||||
import { PostCard } from "./PostCard";
|
||||
|
||||
/**
|
||||
* Shared published-post listing used by /, /posts and /tags/[slug].
|
||||
* Requests beyond the last page 404 instead of rendering an empty page.
|
||||
*/
|
||||
export async function PostListSection({
|
||||
page,
|
||||
perPage,
|
||||
excerptWords,
|
||||
tagId,
|
||||
basePath,
|
||||
emptyMessage,
|
||||
}: {
|
||||
page: number;
|
||||
perPage: number;
|
||||
excerptWords: number;
|
||||
tagId?: number;
|
||||
basePath: string;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
const result = await listPublishedPosts({ page, perPage, tagId });
|
||||
|
||||
if (page > 1 && page > result.pageCount) notFound();
|
||||
if (result.total === 0) return <EmptyState>{emptyMessage}</EmptyState>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
{result.items.map((post) => (
|
||||
<PostCard key={post.id} post={post} excerptWords={excerptWords} />
|
||||
))}
|
||||
</div>
|
||||
<PaginationNav page={result.page} pageCount={result.pageCount} basePath={basePath} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
src/components/public/SiteFooter.tsx
Normal file
9
src/components/public/SiteFooter.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export function SiteFooter({ text }: { text: string }) {
|
||||
return (
|
||||
<footer className="mt-16 border-t border-edge bg-surface">
|
||||
<div className="container-site py-8 text-center text-sm text-ink-muted">
|
||||
{text ? <p>{text}</p> : <p aria-hidden="true"> </p>}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
49
src/components/public/SiteHeader.tsx
Normal file
49
src/components/public/SiteHeader.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import Link from "next/link";
|
||||
import type { NavLink } from "@/lib/services/settings";
|
||||
import type { PublicTag } from "@/lib/services/tags";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
import { NavLinkItem } from "./NavLinkItem";
|
||||
|
||||
export function SiteHeader({
|
||||
siteTitle,
|
||||
headerText,
|
||||
nav,
|
||||
tags,
|
||||
}: {
|
||||
siteTitle: string;
|
||||
headerText: string;
|
||||
nav: NavLink[];
|
||||
tags: PublicTag[];
|
||||
}) {
|
||||
return (
|
||||
<header className="border-b border-edge bg-surface">
|
||||
<div className="container-site flex items-center justify-between gap-4 py-4 sm:py-5">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold tracking-tight text-ink-bright transition-colors hover:text-link sm:text-2xl"
|
||||
>
|
||||
{siteTitle}
|
||||
</Link>
|
||||
{headerText && (
|
||||
<p className="mt-0.5 hidden text-sm text-ink-muted sm:block">{headerText}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{nav.length > 0 && (
|
||||
<nav aria-label="Main navigation" className="hidden lg:block">
|
||||
<ul className="flex items-center gap-1">
|
||||
{nav.map((link) => (
|
||||
<li key={`${link.href}-${link.label}`}>
|
||||
<NavLinkItem link={link} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
<MobileMenu nav={nav} tags={tags} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
44
src/components/public/SiteSidebar.tsx
Normal file
44
src/components/public/SiteSidebar.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import Link from "next/link";
|
||||
import type { PublicTag } from "@/lib/services/tags";
|
||||
|
||||
/** Desktop-only sidebar; MobileMenu covers small screens. */
|
||||
export function SiteSidebar({ tags }: { tags: PublicTag[] }) {
|
||||
return (
|
||||
<aside className="hidden lg:block" aria-label="Browse posts">
|
||||
<div className="sticky top-8 rounded-lg border border-edge bg-surface p-5">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-muted">
|
||||
Browse
|
||||
</h2>
|
||||
<nav aria-label="Posts by tag" className="mt-3">
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/posts"
|
||||
className="block rounded-md px-2 py-1.5 font-medium text-ink-strong transition-colors hover:bg-background hover:text-link"
|
||||
>
|
||||
All posts
|
||||
</Link>
|
||||
</li>
|
||||
{tags.map((tag) => (
|
||||
<li key={tag.id}>
|
||||
<Link
|
||||
href={`/tags/${tag.slug}`}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-ink transition-colors hover:bg-background hover:text-link"
|
||||
>
|
||||
<span className="truncate">{tag.name}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="rounded-full border border-edge bg-background px-2 py-0.5 text-xs text-ink-muted"
|
||||
>
|
||||
{tag.postCount}
|
||||
</span>
|
||||
<span className="sr-only">({tag.postCount} posts)</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
20
src/components/public/TagChips.tsx
Normal file
20
src/components/public/TagChips.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import Link from "next/link";
|
||||
import type { Tag } from "@/db/schema";
|
||||
|
||||
export function TagChips({ tags, className }: { tags: Tag[]; className?: string }) {
|
||||
if (tags.length === 0) return null;
|
||||
return (
|
||||
<ul className={`flex flex-wrap gap-2 ${className ?? ""}`}>
|
||||
{tags.map((tag) => (
|
||||
<li key={tag.id}>
|
||||
<Link
|
||||
href={`/tags/${tag.slug}`}
|
||||
className="inline-flex items-center rounded-full border border-edge bg-background px-2.5 py-0.5 text-xs text-ink-muted transition-colors hover:border-link hover:text-link"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
105
src/components/ui.tsx
Normal file
105
src/components/ui.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import Link from "next/link";
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
SelectHTMLAttributes,
|
||||
TextareaHTMLAttributes,
|
||||
} from "react";
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const buttonBase =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
export const buttonVariants = {
|
||||
primary: `${buttonBase} bg-link text-ink-inverse hover:bg-link-hover`,
|
||||
secondary: `${buttonBase} border border-edge-strong bg-transparent text-ink hover:border-ink-muted hover:text-ink-strong`,
|
||||
danger: `${buttonBase} border border-danger/50 bg-transparent text-danger hover:bg-danger/10`,
|
||||
ghost: `${buttonBase} px-2 py-1 text-ink-muted hover:text-ink-strong`,
|
||||
} as const;
|
||||
export type ButtonVariant = keyof typeof buttonVariants;
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
className,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant }) {
|
||||
return <button {...props} className={cx(buttonVariants[variant], className)} />;
|
||||
}
|
||||
|
||||
export function LinkButton({
|
||||
variant = "primary",
|
||||
className,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
className?: string;
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className={cx(buttonVariants[variant], className)}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldBase =
|
||||
"w-full rounded-md border border-edge bg-background px-3 py-2 text-sm text-ink placeholder:text-ink-muted disabled:opacity-60 aria-invalid:border-danger";
|
||||
|
||||
export function Input({
|
||||
className,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={cx(fieldBase, className)} />;
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
...props
|
||||
}: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea {...props} className={cx(fieldBase, className)} />;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
className,
|
||||
...props
|
||||
}: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return <select {...props} className={cx(fieldBase, className)} />;
|
||||
}
|
||||
|
||||
export function Label({
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<label htmlFor={htmlFor} className={cx("mb-1.5 block text-sm font-medium text-ink-strong", className)}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorText({ id, children }: { id?: string; children?: React.ReactNode }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<p id={id} className="mt-1.5 text-sm text-danger">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpText({ id, children }: { id?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p id={id} className="mt-1.5 text-xs text-ink-muted">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
28
src/db/index.ts
Normal file
28
src/db/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "./schema";
|
||||
|
||||
function createPool(): Pool {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"DATABASE_URL is not set. Copy .env.example to .env and adjust it if needed.",
|
||||
);
|
||||
}
|
||||
// The pool connects lazily, so constructing it here is safe at build time.
|
||||
return new Pool({ connectionString: url, max: 10 });
|
||||
}
|
||||
|
||||
// Reuse the pool across Next.js dev-server hot reloads.
|
||||
const globalStore = globalThis as unknown as { __blogDbPool?: Pool };
|
||||
|
||||
export const pool: Pool = globalStore.__blogDbPool ?? createPool();
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalStore.__blogDbPool = pool;
|
||||
}
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
export type Database = typeof db;
|
||||
/** Either the root client or a transaction handle — services accept both. */
|
||||
export type DbClient = Database | Parameters<Parameters<Database["transaction"]>[0]>[0];
|
||||
161
src/db/schema.ts
Normal file
161
src/db/schema.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const contentStatusEnum = pgEnum("content_status", ["draft", "published"]);
|
||||
export const homeModeEnum = pgEnum("home_mode", ["posts", "tag", "page"]);
|
||||
export const themeEnum = pgEnum("theme", [
|
||||
"solarized-dark",
|
||||
"solarized-light",
|
||||
"dracula",
|
||||
"nord",
|
||||
"gruvbox-dark",
|
||||
"mono",
|
||||
"mono-dark",
|
||||
"catppuccin-mocha",
|
||||
"catppuccin-latte",
|
||||
"tokyo-night",
|
||||
"one-dark",
|
||||
"rose-pine",
|
||||
"everforest-dark",
|
||||
"monokai",
|
||||
"github-light",
|
||||
]);
|
||||
export const fontEnum = pgEnum("font", [
|
||||
"geist",
|
||||
"inter",
|
||||
"lora",
|
||||
"merriweather",
|
||||
"jetbrains-mono",
|
||||
"source-serif",
|
||||
"eb-garamond",
|
||||
"playfair-display",
|
||||
"open-sans",
|
||||
"work-sans",
|
||||
"atkinson-hyperlegible",
|
||||
"space-grotesk",
|
||||
]);
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
username: text("username").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const sessions = pgTable("sessions", {
|
||||
// sha-256 hex digest of the bearer token; the raw token never touches the DB.
|
||||
id: text("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const posts = pgTable(
|
||||
"posts",
|
||||
{
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
title: text("title").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
body: text("body").notNull().default(""),
|
||||
authorName: text("author_name").notNull(),
|
||||
featuredImageUrl: text("featured_image_url"),
|
||||
featuredImageAlt: text("featured_image_alt"),
|
||||
status: contentStatusEnum("status").notNull().default("draft"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => [index("posts_status_published_at_idx").on(t.status, t.publishedAt)],
|
||||
);
|
||||
|
||||
export const tags = pgTable("tags", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: text("name").notNull().unique(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
});
|
||||
|
||||
export const postTags = pgTable(
|
||||
"post_tags",
|
||||
{
|
||||
postId: integer("post_id")
|
||||
.notNull()
|
||||
.references(() => posts.id, { onDelete: "cascade" }),
|
||||
tagId: integer("tag_id")
|
||||
.notNull()
|
||||
.references(() => tags.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.postId, t.tagId] }),
|
||||
index("post_tags_tag_id_idx").on(t.tagId),
|
||||
],
|
||||
);
|
||||
|
||||
export const pages = pgTable("pages", {
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
title: text("title").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
body: text("body").notNull().default(""),
|
||||
status: contentStatusEnum("status").notNull().default("draft"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const settings = pgTable(
|
||||
"settings",
|
||||
{
|
||||
// Single-row table; the row always has id = 1 (enforced below).
|
||||
id: integer("id").primaryKey(),
|
||||
siteTitle: text("site_title").notNull().default("My Blog"),
|
||||
headerText: text("header_text").notNull().default(""),
|
||||
footerText: text("footer_text").notNull().default(""),
|
||||
postsPerPage: integer("posts_per_page").notNull().default(10),
|
||||
excerptWords: integer("excerpt_words").notNull().default(40),
|
||||
homeMode: homeModeEnum("home_mode").notNull().default("posts"),
|
||||
homeTagId: integer("home_tag_id").references(() => tags.id, { onDelete: "set null" }),
|
||||
homePageId: integer("home_page_id").references(() => pages.id, { onDelete: "set null" }),
|
||||
theme: themeEnum("theme").notNull().default("solarized-dark"),
|
||||
font: fontEnum("font").notNull().default("geist"),
|
||||
},
|
||||
(t) => [
|
||||
check("settings_single_row_check", sql`${t.id} = 1`),
|
||||
check("settings_posts_per_page_check", sql`${t.postsPerPage} BETWEEN 1 AND 50`),
|
||||
check("settings_excerpt_words_check", sql`${t.excerptWords} BETWEEN 5 AND 200`),
|
||||
],
|
||||
);
|
||||
|
||||
export const navItems = pgTable(
|
||||
"nav_items",
|
||||
{
|
||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
||||
label: text("label").notNull(),
|
||||
// A nav item points at exactly one target: an external/internal URL...
|
||||
url: text("url"),
|
||||
// ...or a static page (removed automatically when the page is deleted).
|
||||
pageId: integer("page_id").references(() => pages.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(t) => [check("nav_items_target_check", sql`(${t.url} IS NULL) <> (${t.pageId} IS NULL)`)],
|
||||
);
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type Session = typeof sessions.$inferSelect;
|
||||
export type Post = typeof posts.$inferSelect;
|
||||
export type Tag = typeof tags.$inferSelect;
|
||||
export type Page = typeof pages.$inferSelect;
|
||||
export type Settings = typeof settings.$inferSelect;
|
||||
export type NavItem = typeof navItems.$inferSelect;
|
||||
export type ContentStatus = Post["status"];
|
||||
export type HomeMode = Settings["homeMode"];
|
||||
export type Theme = Settings["theme"];
|
||||
export type Font = Settings["font"];
|
||||
22
src/lib/auth/cookies.ts
Normal file
22
src/lib/auth/cookies.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { cookies } from "next/headers";
|
||||
|
||||
export const SESSION_COOKIE = "blog_session";
|
||||
|
||||
/** Only callable from Server Actions / Route Handlers (Next restriction). */
|
||||
export async function setSessionCookie(token: string, expiresAt: Date): Promise<void> {
|
||||
(await cookies()).set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSessionCookie(): Promise<void> {
|
||||
(await cookies()).delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function readSessionCookie(): Promise<string | undefined> {
|
||||
return (await cookies()).get(SESSION_COOKIE)?.value;
|
||||
}
|
||||
21
src/lib/auth/dal.ts
Normal file
21
src/lib/auth/dal.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { readSessionCookie } from "./cookies";
|
||||
import { type SessionUser, validateSessionToken } from "./session";
|
||||
|
||||
/**
|
||||
* Data-access-layer auth guard. Every admin page AND every mutating server
|
||||
* action calls one of these on the server — route protection never relies
|
||||
* on the client. `cache` dedupes the DB lookup within a single request.
|
||||
*/
|
||||
export const getSessionUser = cache(async (): Promise<SessionUser | null> => {
|
||||
const token = await readSessionCookie();
|
||||
if (!token) return null;
|
||||
return validateSessionToken(token);
|
||||
});
|
||||
|
||||
export async function requireAdmin(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/admin/login");
|
||||
return user;
|
||||
}
|
||||
47
src/lib/auth/password.ts
Normal file
47
src/lib/auth/password.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
||||
|
||||
// scrypt parameters (OWASP-recommended baseline). Encoded into each hash
|
||||
// so they can be raised later without invalidating existing hashes.
|
||||
const SCRYPT_N = 16384;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const KEY_LENGTH = 64;
|
||||
|
||||
function deriveKey(password: string, salt: Buffer, N: number, r: number, p: number) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(password, salt, KEY_LENGTH, { N, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) =>
|
||||
err ? reject(err) : resolve(key),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Format: scrypt$N$r$p$saltBase64$hashBase64 */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const key = await deriveKey(password, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P);
|
||||
return [
|
||||
"scrypt",
|
||||
SCRYPT_N,
|
||||
SCRYPT_R,
|
||||
SCRYPT_P,
|
||||
salt.toString("base64"),
|
||||
key.toString("base64"),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
export async function verifyPassword(stored: string, password: string): Promise<boolean> {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
||||
const N = Number(parts[1]);
|
||||
const r = Number(parts[2]);
|
||||
const p = Number(parts[3]);
|
||||
if (![N, r, p].every((n) => Number.isSafeInteger(n) && n > 0)) return false;
|
||||
try {
|
||||
const salt = Buffer.from(parts[4], "base64");
|
||||
const expected = Buffer.from(parts[5], "base64");
|
||||
const actual = await deriveKey(password, salt, N, r, p);
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
52
src/lib/auth/session.ts
Normal file
52
src/lib/auth/session.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { eq, lt } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { sessions, users } from "@/db/schema";
|
||||
|
||||
// Deliberately free of next/* imports so it can be exercised directly by
|
||||
// integration tests; cookie handling lives in cookies.ts.
|
||||
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
export type SessionUser = { id: number; username: string };
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/** Creates a DB session and returns the raw bearer token for the cookie. */
|
||||
export async function createSession(
|
||||
userId: number,
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
await db.insert(sessions).values({ id: hashToken(token), userId, expiresAt });
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
/** Resolves a raw token to its user, treating expired sessions as absent. */
|
||||
export async function validateSessionToken(token: string): Promise<SessionUser | null> {
|
||||
if (!token) return null;
|
||||
const id = hashToken(token);
|
||||
const [row] = await db
|
||||
.select({ userId: users.id, username: users.username, expiresAt: sessions.expiresAt })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.where(eq(sessions.id, id))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
if (row.expiresAt.getTime() <= Date.now()) {
|
||||
await db.delete(sessions).where(eq(sessions.id, id));
|
||||
return null;
|
||||
}
|
||||
return { id: row.userId, username: row.username };
|
||||
}
|
||||
|
||||
export async function deleteSession(token: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.id, hashToken(token)));
|
||||
}
|
||||
|
||||
/** Opportunistic cleanup, called on login so the table cannot grow unbounded. */
|
||||
export async function deleteExpiredSessions(): Promise<void> {
|
||||
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
|
||||
}
|
||||
33
src/lib/excerpt.ts
Normal file
33
src/lib/excerpt.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Element, Root } from "hast";
|
||||
import { toText } from "hast-util-to-text";
|
||||
import rehypeParse from "rehype-parse";
|
||||
import { unified } from "unified";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
const parser = unified().use(rehypeParse, { fragment: true });
|
||||
|
||||
/**
|
||||
* Produces a plain-text excerpt from a stored HTML body. Code blocks
|
||||
* (<pre>) are dropped — they read as noise in a one-line summary — while
|
||||
* inline code, link text, and emphasis text are kept. Images contribute
|
||||
* nothing (alt text is an attribute, not visible text). The result is
|
||||
* capped at `wordLimit` words with an ellipsis when content was cut off.
|
||||
*/
|
||||
export function generateExcerpt(html: string, wordLimit: number): string {
|
||||
const limit = Math.max(1, Math.floor(wordLimit));
|
||||
const tree = parser.parse(html) as Root;
|
||||
|
||||
visit(tree, "element", (node: Element, index, parent) => {
|
||||
if (node.tagName === "pre" && parent && typeof index === "number") {
|
||||
parent.children.splice(index, 1);
|
||||
return index;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// toText mimics innerText: block boundaries become line breaks.
|
||||
const words = toText(tree).split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return "";
|
||||
const excerpt = words.slice(0, limit).join(" ");
|
||||
return words.length > limit ? `${excerpt}…` : excerpt;
|
||||
}
|
||||
15
src/lib/format.ts
Normal file
15
src/lib/format.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Fixed locale and UTC keep server-rendered dates deterministic
|
||||
// (no hydration mismatches, no test flakiness across machines).
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
export function formatDate(date: Date | null | undefined): string {
|
||||
if (!date) return "—";
|
||||
return dateFormatter.format(date);
|
||||
}
|
||||
|
||||
export function isoDate(date: Date | null | undefined): string | undefined {
|
||||
return date ? date.toISOString() : undefined;
|
||||
}
|
||||
28
src/lib/forms.ts
Normal file
28
src/lib/forms.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ZodError } from "zod";
|
||||
|
||||
/** Result shape shared by all admin form actions (via useActionState). */
|
||||
export type FormState = {
|
||||
status?: "success";
|
||||
/** Form-level message not tied to a single field. */
|
||||
formError?: string;
|
||||
/** Per-field messages, keyed by input name. */
|
||||
fieldErrors?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export const initialFormState: FormState = {};
|
||||
|
||||
export function zodErrorToFormState(error: ZodError): FormState {
|
||||
const fieldErrors: Record<string, string[]> = {};
|
||||
for (const issue of error.issues) {
|
||||
const key = issue.path.length > 0 ? String(issue.path[0]) : "_form";
|
||||
(fieldErrors[key] ??= []).push(issue.message);
|
||||
}
|
||||
return { fieldErrors };
|
||||
}
|
||||
|
||||
export function firstFieldError(
|
||||
state: FormState | undefined,
|
||||
field: string,
|
||||
): string | undefined {
|
||||
return state?.fieldErrors?.[field]?.[0];
|
||||
}
|
||||
21
src/lib/html.ts
Normal file
21
src/lib/html.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import rehypeParse from "rehype-parse";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import rehypeStringify from "rehype-stringify";
|
||||
import { unified } from "unified";
|
||||
import { contentSchema } from "./sanitize-schema";
|
||||
|
||||
/**
|
||||
* Post/page bodies are stored as HTML produced by the admin editor.
|
||||
* This is the single render-side gate: whatever is in the database goes
|
||||
* through the allowlist before reaching a browser. It runs on save too,
|
||||
* so stored content is already clean — rendering twice is idempotent
|
||||
* defense in depth, same policy the markdown pipeline had.
|
||||
*/
|
||||
const processor = unified()
|
||||
.use(rehypeParse, { fragment: true })
|
||||
.use(rehypeSanitize, contentSchema)
|
||||
.use(rehypeStringify);
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
return processor.processSync(html).toString();
|
||||
}
|
||||
29
src/lib/markdown.ts
Normal file
29
src/lib/markdown.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import rehypeStringify from "rehype-stringify";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import { unified } from "unified";
|
||||
import { contentSchema } from "./sanitize-schema";
|
||||
|
||||
/**
|
||||
* GitHub-flavored Markdown -> sanitized HTML.
|
||||
*
|
||||
* Bodies are stored as HTML (see src/lib/html.ts), so this pipeline now
|
||||
* serves two jobs: converting markdown pasted into the editor and
|
||||
* converting the markdown-authored seed content at seed time. It shares
|
||||
* the same sanitize schema as the storage pipeline, so both routes admit
|
||||
* exactly the same HTML.
|
||||
*/
|
||||
const processor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeSanitize, contentSchema)
|
||||
.use(rehypeStringify);
|
||||
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
return processor.processSync(markdown).toString();
|
||||
}
|
||||
19
src/lib/pagination.ts
Normal file
19
src/lib/pagination.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Hard ceiling so a hostile ?page= value cannot produce absurd OFFSETs.
|
||||
const MAX_PAGE = 100_000;
|
||||
|
||||
/**
|
||||
* Parses a `?page=` search param into a safe positive integer.
|
||||
* Anything malformed (missing, negative, zero, non-numeric, scientific
|
||||
* notation, arrays with junk) collapses to page 1.
|
||||
*/
|
||||
export function parsePage(raw: string | string[] | undefined): number {
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!value || !/^\d+$/.test(value)) return 1;
|
||||
const page = Number.parseInt(value, 10);
|
||||
if (!Number.isSafeInteger(page) || page < 1) return 1;
|
||||
return Math.min(page, MAX_PAGE);
|
||||
}
|
||||
|
||||
export function pageCountFor(total: number, perPage: number): number {
|
||||
return Math.max(1, Math.ceil(total / Math.max(1, perPage)));
|
||||
}
|
||||
6
src/lib/params.ts
Normal file
6
src/lib/params.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** Parses a numeric route param (e.g. /admin/posts/[id]); null when invalid. */
|
||||
export function parseIdParam(raw: string): number | null {
|
||||
if (!/^\d+$/.test(raw)) return null;
|
||||
const id = Number.parseInt(raw, 10);
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
20
src/lib/sanitize-schema.ts
Normal file
20
src/lib/sanitize-schema.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defaultSchema } from "rehype-sanitize";
|
||||
|
||||
/**
|
||||
* Shared sanitization schema for ALL user-authored content (stored editor
|
||||
* HTML and markdown converted on paste/seed). Based on GitHub's schema:
|
||||
* <script> is dropped with its content, event handlers and javascript:
|
||||
* URLs never survive, and only the allowlist below renders.
|
||||
*
|
||||
* Additions over the GitHub defaults, all inert:
|
||||
* - u: Tiptap's underline mark
|
||||
* - colgroup/col: Tiptap table column scaffolding
|
||||
*/
|
||||
export const contentSchema = {
|
||||
...defaultSchema,
|
||||
tagNames: [...(defaultSchema.tagNames ?? []), "u", "colgroup", "col"],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
col: ["span"],
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue