Compare commits

..
10 Commits
Author SHA1 Message Date
bbeandCursor ede8817583 fix: harden deploys and close top security holes after /events outage
Isolate next dev from production .next, add build-guard/atomic deploy/health
watchdog, error boundaries, and fix JWT startup, meetup leaks, media path
traversal, SVG/memory uploads, and JSON-LD escaping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 19:14:57 +02:00
bbeandCursor 495289232b feat: blog header images, npub display, and meetups load more
Show longform image tags on posts, use shortened npubs for author names,
and paginate the homepage meetups section with responsive load more.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 04:36:10 +02:00
bbeandCursor 2ef68222bf feat: resolve live Nostr references in blog posts and add embeds
Support naddr/nevent/note slugs for unindexed posts, cache naddr lookups,
render Nostr embeds in markdown, and add a consistency check for events mirrors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 03:57:32 +02:00
bbeandCursor 6023991f5c feat: add SEO metadata, llms.txt, and markdown page mirrors
Centralize site metadata and social URLs for JSON-LD, expose llmstxt.org
content and per-page .md routes for LLM crawlers, and refactor blog/FAQ/events
pages with shared components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:59:41 +02:00
bbeandCursor 99380ef6aa fix: auto-publish approved blog submissions on approval
Approved user submissions now import into the blog automatically, with a
backfill script for existing approvals and a WebSocket polyfill so backend
Nostr relay queries work on Node 20.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 23:38:48 +02:00
bbeandCursor a6a2b113ee feat: add scoped API keys for programmatic site access
Introduce ApiKey model, CRUD endpoints, and admin UI so agents can
authenticate with permission-scoped keys. Normalize pubkeys to hex on login,
dedupe legacy npub/hex user rows, and ignore .cursor in git.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 09:29:30 +02:00
bbeandCursor 70e3e0633d feat: roles/permissions system and Nostr profile display on admin users
Introduce granular role-based permissions with SuperAdmin env override, admin roles UI, and permission-gated API routes. Fix admin user Nostr metadata by batching relay profile fetches, normalizing npub pubkeys to hex, and adding reusable NostrAvatar/useNostrProfile components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 08:46:56 +02:00
bbeandCursor 3bdd01dedf feat: user relay management, blog naddr sync, and OG image update
Add UserRelay API and dashboard relays tab with NIP-65 import, store post
naddr for Nostr articles, and ship Prisma migrations for board tables,
user relays, and post metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 05:54:34 +02:00
bbe 78271ea110 feat: organizers, meetups UI, Plausible analytics, and migration tooling
- Add organizer model/API, admin and public organizer pages, meetup cards
- Refresh events/home/contact; add calendar dialog and carousel components
- Optional Plausible via NEXT_PUBLIC_PLAUSIBLE_* env vars in root layout
- Prisma migration, seed updates, baseline-and-migrate script

Made-with: Cursor
2026-04-04 21:55:34 +02:00
bbe 586b572f73 feat(board): Lightning-paid message board with LNbits and admin moderation
Add public /board flow: create invoice, webhook + confirm reconciliation, list
active messages, likes (Nostr), zap fallbacks. Admin table for hide/delete.

Include LNbits webhook body normalization (double-encoded JSON), POST
/api/messages/confirm/:hash, and root npm db:push script. Prisma models for
pending invoices and board messages.

Made-with: Cursor
2026-04-03 18:37:52 +02:00
132 changed files with 9057 additions and 1508 deletions
+29 -3
View File
@@ -1,10 +1,18 @@
# Admin pubkeys (comma-separated hex pubkeys)
ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# SuperAdmin pubkeys (comma-separated hex or npub). SuperAdmins always have every
# permission, are env-only, and can never be edited or removed through the UI.
# SUPERADMIN_PUBKEYS replaces the older ADMIN_PUBKEYS. If SUPERADMIN_PUBKEYS is
# unset, the app falls back to ADMIN_PUBKEYS so existing deployments keep working.
SUPERADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# Nostr relays (comma-separated)
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
# Database (use an absolute `file:` URL in production, e.g. file:/home/bbe/BelgianBitcoinEmbassy/backend/prisma/prod.db)
# Database (path is relative to the backend working directory for file: URLs — keep deploy cwd consistent)
# After pulling code: from backend run `npm run migrate:deploy` (or `npm run db:migrate`) so migrations
# run in order. Do not rely on `db:push` for upgrades that add required columns to non-empty tables.
#
# If migrate fails with P3005 (schema not empty / no migration history, e.g. DB was created with db push),
# from backend run once: `npm run db:baseline-and-migrate` (see scripts/baseline-and-migrate.sh).
DATABASE_URL="file:./dev.db"
# JWT
@@ -22,3 +30,21 @@ NEXT_PUBLIC_API_URL=http://localhost:4000/api
NEXT_PUBLIC_SITE_URL=https://belgianbitcoinembassy.org
NEXT_PUBLIC_SITE_TITLE=Belgian Bitcoin Embassy
NEXT_PUBLIC_SITE_TAGLINE=Belgium's Monthly Bitcoin Meetup
# Plausible analytics (optional; both must be set or the script is omitted)
# Tracked site domain (data-domain). Example: belgianbitcoinembassy.org
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=belgianbitcoinembassy.org
# Plausible / custom analytics host origin, no trailing slash. Example: https://analytics.azzamo.net
NEXT_PUBLIC_PLAUSIBLE_ANALYTICS_ORIGIN=https://analytics.azzamo.net
# Message board (Lightning / LNbits) — backend
MESSAGE_PRICE_SATS=1000
LNBITS_API_KEY=
LNBITS_WEBHOOK_SECRET=
LNBITS_URL=https://legend.lnbits.com
# Public URL that LNbits can POST webhooks to (usually your site origin so /api/messages/webhook hits the API)
WEBHOOK_BASE_URL=http://localhost:3000
# Optional: lnaddress or LNURL-pay string for “Zap BBE” when the message has no pubkey
BOARD_ZAP_LN_ADDRESS=
# Optional: hex pubkey for njump fallback when BOARD_ZAP_LN_ADDRESS is unset
BOARD_ZAP_PUBKEY=
+4
View File
@@ -3,6 +3,9 @@ node_modules/
# Next.js
frontend/.next/
frontend/.next-dev/
frontend/.next-build/
frontend/.next-prev/
frontend/out/
# Backend build
@@ -21,6 +24,7 @@ Thumbs.db
# IDE
.idea/
.vscode/
.cursor/
*.swp
*.swo
+41
View File
@@ -0,0 +1,41 @@
# Changelog
## Unreleased
### Roles and permissions system
Replaced the single moderator promote/demote toggle with a full role hierarchy
and a runtime-editable, granular permission system.
- **Role hierarchy**: SuperAdmin (env-only) > Admin > Moderator > Writer > Guest
(absence of a role). SuperAdmin always holds every permission and can never be
edited, demoted, or removed through the UI.
- **New env var `SUPERADMIN_PUBKEYS`** (comma-separated hex or npub) replaces the
old env-admin concept. Falls back to `ADMIN_PUBKEYS` when unset so existing
deployments keep working.
- **Permission registry**: a central list of granular keys in
`backend/src/constants/permissions.ts` is the single source of truth. Adding a
key there surfaces it automatically in the API and the Roles matrix page.
- **Authorization**: a single `requires(permission)` guard replaces the old
role-name middleware and is applied to every protected endpoint. Roles and
permissions are resolved live per request, so a stale JWT no longer drives
access. SuperAdmin bypasses all checks.
- **Data model**: `User.role` is now nullable (null means Guest) and uses the
lowercase enum `admin`, `moderator`, `writer`. A new `RolePermission` table
maps roles to permission keys. A migration backfills legacy uppercase roles and
seeds default permission sets; it is idempotent and safe on a live database.
- **New endpoints**: `GET /api/auth/me`, `GET /api/admin/permissions`,
`GET /api/admin/roles`, `PUT /api/admin/roles/:role/permissions`, and
`PUT /api/users/:pubkey/role` (replacing `POST /api/users/promote` and
`POST /api/users/demote`).
- **Dashboard**: User Management now uses a per-user role selector with hierarchy
enforcement and a locked SuperAdmin badge. A new SuperAdmin-only Roles page
renders a permission-by-role matrix. Nav items are gated on resolved
permissions. The NIP-05 reserved-username assignment is unchanged.
#### Migration notes
After pulling, from `backend` run `npm run migrate:deploy` (or, for databases
created with `db push`, `npm run db:baseline-and-migrate`). Set
`SUPERADMIN_PUBKEYS` in your `.env`. Existing moderators become `moderator`,
existing database admins become `admin`, and prior env admins become SuperAdmin.
+44 -5
View File
@@ -31,7 +31,7 @@ cp .env.example backend/.env
cp .env.example frontend/.env.local
```
Edit `backend/.env` with your admin pubkeys and a secure JWT secret.
Edit `backend/.env` with your SuperAdmin pubkeys (`SUPERADMIN_PUBKEYS`) and a secure JWT secret.
### 3. Set up database
@@ -91,15 +91,54 @@ npm run dev
| PATCH | /api/meetups/:id | Update meetup |
| POST | /api/moderation/hide | Hide content |
| POST | /api/moderation/block | Block pubkey |
| GET | /api/auth/me | Current user's effective role and permissions |
| GET | /api/users | List users |
| POST | /api/users/promote | Promote user |
| PUT | /api/users/:pubkey/role | Assign a role to a user |
| GET | /api/admin/permissions | Permission registry (keys, labels, groups) |
| GET | /api/admin/roles | Each role and its permission set |
| PUT | /api/admin/roles/:role/permissions | Update a role's permissions |
| GET | /api/categories | List categories |
| POST | /api/categories | Create category |
## Roles
## Roles and permissions
- **Admin**: Full access. Defined by pubkeys in `.env`
- **Moderator**: Content moderation. Assigned by admins via dashboard.
Access is controlled by an ordered role hierarchy backed by a runtime-editable,
granular permission system. From highest to lowest:
1. **SuperAdmin** sourced only from the `SUPERADMIN_PUBKEYS` env var. Always has
every permission. Cannot be created, edited, demoted, or removed through the
UI. This replaces the old "admin set in env" concept. If `SUPERADMIN_PUBKEYS`
is unset, the app falls back to the legacy `ADMIN_PUBKEYS` variable.
2. **Admin** highest role assignable through the dashboard.
3. **Moderator**
4. **Writer**
5. **Guest** the fallback for any logged-in pubkey with no assigned role. Guest is
not a stored record, it is simply the absence of a role.
Each assignable role (Admin, Moderator, Writer) maps to a set of permission keys
stored in the `RolePermission` table. SuperAdmins manage these from the dashboard
**Roles** page, which renders a permission-by-role matrix. The dashboard nav and
actions are gated on the current user's resolved permissions, not on role names.
### Safeguards
- SuperAdmin is never assignable through the UI or API. Only the env var grants it.
- A user cannot assign a role at or above their own, or modify a SuperAdmin.
- A user cannot grant a role a permission they do not themselves hold.
- A user cannot remove their own `users.assign_role` or `roles.edit_permissions`.
SuperAdmin (env-sourced) is always the recovery path.
- Unknown role values and permission keys are rejected by the server.
### Adding a new permission key
1. Add an entry to `PERMISSIONS` in
[backend/src/constants/permissions.ts](backend/src/constants/permissions.ts)
with a `key`, `label`, and `group`.
2. The key automatically appears in `GET /admin/permissions` and the Roles matrix
page, so no UI changes are needed.
3. Apply it to the relevant endpoints with the `requires('<key>')` guard.
4. Optionally grant it to roles by default in `DEFAULT_ROLE_PERMISSIONS` (used by
the seed) and in the migration so fresh and existing databases get it.
## License
+34 -1
View File
@@ -20,7 +20,8 @@
"nostr-tools": "^2.10.0",
"slugify": "^1.6.8",
"ulid": "^3.0.2",
"uuid": "^11.0.0"
"uuid": "^11.0.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -30,6 +31,7 @@
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
@@ -807,6 +809,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -2553,6 +2565,27 @@
"engines": {
"node": ">= 8"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+10 -5
View File
@@ -5,10 +5,13 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:push": "dotenv -e ../.env -- prisma db push",
"db:seed": "dotenv -e ../.env -- prisma db seed",
"db:studio": "dotenv -e ../.env -- prisma studio",
"migrate:deploy": "dotenv -e ../.env -- prisma migrate deploy"
"db:push": "dotenv -e ../.env -e .env -- prisma db push",
"db:migrate": "dotenv -e ../.env -e .env -- prisma migrate deploy",
"db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy",
"backfill-submissions": "dotenv -e ../.env -e .env -- tsx scripts/backfill-approved-submissions.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
@@ -26,7 +29,8 @@
"nostr-tools": "^2.10.0",
"slugify": "^1.6.8",
"ulid": "^3.0.2",
"uuid": "^11.0.0"
"uuid": "^11.0.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -36,6 +40,7 @@
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
@@ -0,0 +1,47 @@
-- CreateTable
CREATE TABLE "Organizer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE UNIQUE INDEX "Organizer_slug_key" ON "Organizer"("slug");
INSERT INTO "Organizer" ("id", "name", "slug", "createdAt", "updatedAt")
VALUES (
'00000000-0000-4000-8000-000000000001',
'Belgian Bitcoin Embassy',
'belgian-bitcoin-embassy',
datetime('now'),
datetime('now')
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Meetup" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"location" TEXT NOT NULL,
"link" TEXT,
"imageId" TEXT,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"featured" BOOLEAN NOT NULL DEFAULT false,
"visibility" TEXT NOT NULL DEFAULT 'PUBLIC',
"organizerId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Meetup_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "Organizer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "organizerId", "status", "time", "title", "updatedAt", "visibility")
SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", '00000000-0000-4000-8000-000000000001', "status", "time", "title", "updatedAt", "visibility" FROM "Meetup";
DROP TABLE "Meetup";
ALTER TABLE "new_Meetup" RENAME TO "Meetup";
CREATE INDEX "Meetup_organizerId_idx" ON "Meetup"("organizerId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "BoardInvoicePending" (
"paymentHash" TEXT NOT NULL PRIMARY KEY,
"content" TEXT NOT NULL,
"guestName" TEXT,
"pubkey" TEXT,
"profilePic" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "BoardMessage" (
"id" TEXT NOT NULL PRIMARY KEY,
"paymentHash" TEXT NOT NULL,
"content" TEXT NOT NULL,
"authorName" TEXT NOT NULL,
"pubkey" TEXT,
"profilePic" TEXT,
"satsPaid" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active',
"likeCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "BoardMessage_paymentHash_key" ON "BoardMessage"("paymentHash");
-- DropIndex (cleanup from organizer migration)
DROP INDEX IF EXISTS "Meetup_organizerId_idx";
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "UserRelay" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"url" TEXT NOT NULL,
"read" BOOLEAN NOT NULL DEFAULT true,
"write" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "UserRelay_pubkey_url_key" ON "UserRelay"("pubkey", "url");
@@ -0,0 +1,5 @@
-- AlterTable: add naddr column to Post
ALTER TABLE "Post" ADD COLUMN "naddr" TEXT;
-- AlterTable: set default for content on Post (new rows only; existing rows keep their content)
-- SQLite doesn't support ALTER COLUMN DEFAULT, but Prisma handles this at the client level.
@@ -0,0 +1,83 @@
-- Roles and permissions system.
-- Makes User.role nullable (null means Guest), backfills legacy uppercase roles
-- to the new lowercase enum, adds the RolePermission table, and seeds default
-- permission sets. Safe and idempotent to run on a live database.
-- RedefineTables: make User.role nullable and drop its default, converting values.
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"role" TEXT,
"displayName" TEXT,
"username" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("id", "pubkey", "role", "displayName", "username", "createdAt", "updatedAt")
SELECT
"id",
"pubkey",
CASE
WHEN "role" = 'ADMIN' THEN 'admin'
WHEN "role" = 'MODERATOR' THEN 'moderator'
WHEN "role" = 'WRITER' THEN 'writer'
WHEN "role" IN ('admin', 'moderator', 'writer') THEN "role"
ELSE NULL
END,
"displayName",
"username",
"createdAt",
"updatedAt"
FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateTable
CREATE TABLE IF NOT EXISTS "RolePermission" (
"role" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("role", "permission")
);
-- Seed default permission sets. INSERT OR IGNORE keeps this idempotent and never
-- clobbers permissions an operator has already customized at runtime.
INSERT OR IGNORE INTO "RolePermission" ("role", "permission") VALUES
('admin', 'events.create'),
('admin', 'events.edit'),
('admin', 'events.delete'),
('admin', 'organizers.manage'),
('admin', 'gallery.upload'),
('admin', 'gallery.delete'),
('admin', 'blog.draft'),
('admin', 'blog.publish'),
('admin', 'blog.delete'),
('admin', 'faq.manage'),
('admin', 'submissions.review'),
('admin', 'board.manage'),
('admin', 'moderation.act'),
('admin', 'categories.manage'),
('admin', 'users.assign_role'),
('admin', 'nip05.assign'),
('admin', 'relays.manage'),
('admin', 'settings.edit'),
('admin', 'nostr_tools.use'),
('moderator', 'events.create'),
('moderator', 'events.edit'),
('moderator', 'events.delete'),
('moderator', 'submissions.review'),
('moderator', 'moderation.act'),
('moderator', 'gallery.upload'),
('moderator', 'board.manage'),
('moderator', 'categories.manage'),
('moderator', 'faq.manage'),
('writer', 'blog.draft'),
('writer', 'gallery.upload'),
('writer', 'events.create');
@@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"permissions" TEXT NOT NULL,
"createdByPubkey" TEXT NOT NULL,
"lastUsedAt" DATETIME,
"revokedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
+81 -8
View File
@@ -10,15 +10,50 @@ datasource db {
model User {
id String @id @default(uuid())
pubkey String @unique
role String @default("USER") // USER, MODERATOR, ADMIN
role String? // admin, moderator, writer. Null means Guest (no elevated role). SuperAdmin is env-sourced and never stored.
displayName String?
username String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Scoped API key for programmatic access. The raw key is shown to the creator
// only once; only its SHA-256 hash is stored. `permissions` is a JSON array of
// permission keys that gate what the key may do.
model ApiKey {
id String @id @default(uuid())
name String
prefix String // leading characters of the raw key, for display
keyHash String @unique // SHA-256 hex of the full key
permissions String // JSON array of permission keys
createdByPubkey String
lastUsedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
}
// Maps an assignable role to a granted permission key. Editable at runtime.
// One row per (role, permission). SuperAdmin is never stored here; it bypasses
// all checks via the env list.
model RolePermission {
role String
permission String
createdAt DateTime @default(now())
@@id([role, permission])
}
model Organizer {
id String @id @default(uuid())
name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
meetups Meetup[]
}
model Meetup {
id String @id @default(uuid())
id String @id @default(uuid())
title String
description String
date String
@@ -26,11 +61,13 @@ model Meetup {
location String
link String?
imageId String?
status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
featured Boolean @default(false)
visibility String @default("PUBLIC") // PUBLIC, HIDDEN
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
status String @default("DRAFT") // DRAFT, PUBLISHED, CANCELLED (Upcoming/Past derived from date)
featured Boolean @default(false)
visibility String @default("PUBLIC") // PUBLIC, HIDDEN
organizerId String
organizer Organizer @relation(fields: [organizerId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Media {
@@ -50,9 +87,10 @@ model Media {
model Post {
id String @id @default(uuid())
nostrEventId String @unique
naddr String?
title String
slug String @unique
content String
content String @default("")
excerpt String?
authorPubkey String
authorName String?
@@ -106,6 +144,17 @@ model Relay {
createdAt DateTime @default(now())
}
model UserRelay {
id String @id @default(uuid())
pubkey String
url String
read Boolean @default(true)
write Boolean @default(true)
createdAt DateTime @default(now())
@@unique([pubkey, url])
}
model Setting {
id String @id @default(uuid())
key String @unique
@@ -147,3 +196,27 @@ model Faq {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// Pending Lightning invoice metadata (message is created only after payment via webhook).
model BoardInvoicePending {
paymentHash String @id
content String
guestName String?
pubkey String?
profilePic String?
createdAt DateTime @default(now())
}
/// Paid board message (LNbits payment confirmed).
model BoardMessage {
id String @id @default(uuid())
paymentHash String @unique
content String
authorName String
pubkey String?
profilePic String?
satsPaid Int
status String @default("active") // active, hidden, deleted
likeCount Int @default(0)
createdAt DateTime @default(now())
}
+25 -1
View File
@@ -1,6 +1,7 @@
import dotenv from 'dotenv';
import path from 'path';
import { PrismaClient } from '@prisma/client';
import { DEFAULT_ROLE_PERMISSIONS } from '../src/constants/permissions';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
@@ -56,6 +57,16 @@ async function main() {
});
}
const defaultOrganizer = await prisma.organizer.upsert({
where: { slug: 'belgian-bitcoin-embassy' },
update: {},
create: {
id: '00000000-0000-4000-8000-000000000001',
name: 'Belgian Bitcoin Embassy',
slug: 'belgian-bitcoin-embassy',
},
});
const existingMeetup = await prisma.meetup.findFirst({
where: { title: 'Monthly Bitcoin Meetup' },
});
@@ -70,12 +81,25 @@ async function main() {
time: '19:00',
location: 'Brussels, Belgium',
link: 'https://meetup.com/example',
status: 'UPCOMING',
status: 'PUBLISHED',
featured: true,
organizerId: defaultOrganizer.id,
},
});
}
// Seed default role permissions. This only fills in missing rows so it never
// clobbers permissions an operator has customized at runtime.
for (const [role, permissions] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
for (const permission of permissions) {
await prisma.rolePermission.upsert({
where: { role_permission: { role, permission } },
update: {},
create: { role, permission },
});
}
}
console.log('Seed completed successfully.');
}
@@ -0,0 +1,64 @@
// One-time backfill: publish any APPROVED submissions that never got a blog
// Post (they predate auto-publish-on-approval). Idempotent — already-imported
// submissions are skipped, so it is safe to re-run.
//
// Usage (from backend/):
// npm run backfill-submissions
import { prisma } from '../src/db/prisma';
import { importPostFromNostr, resolveSubmissionImport } from '../src/services/postImport';
async function main() {
const approved = await prisma.submission.findMany({
where: { status: 'APPROVED' },
orderBy: { createdAt: 'asc' },
});
console.log(`Found ${approved.length} APPROVED submission(s).`);
let imported = 0;
let skipped = 0;
let failed = 0;
for (const submission of approved) {
const label = `"${submission.title}" (${submission.id})`;
try {
const importInput = await resolveSubmissionImport(submission);
if (!importInput) {
console.warn(` FAILED ${label}: could not resolve Nostr event from relays.`);
failed++;
continue;
}
const existing = await prisma.post.findUnique({
where: { nostrEventId: importInput.nostrEventId },
});
if (existing) {
console.log(` SKIP ${label}: already published (slug: ${existing.slug}).`);
skipped++;
continue;
}
const post = await importPostFromNostr(importInput);
console.log(` IMPORT ${label}: published (slug: ${post?.slug}).`);
imported++;
} catch (err) {
console.error(` FAILED ${label}:`, err);
failed++;
}
}
console.log(`\nDone. Imported: ${imported}, Skipped: ${skipped}, Failed: ${failed}.`);
}
main()
.then(async () => {
await prisma.$disconnect();
// The Nostr relay pool keeps websockets open, which would otherwise keep the
// process alive after the work is done.
process.exit(0);
})
.catch(async (err) => {
console.error('Backfill error:', err);
await prisma.$disconnect();
process.exit(1);
});
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Use when `npm run db:migrate` fails with P3005 (DB has tables but no migrate history, e.g. after db push).
# Marks historical migrations as already applied, then runs `migrate deploy` to apply pending ones (e.g. organizer).
set -euo pipefail
cd "$(dirname "$0")/.."
for migration_name in \
20260331051150_add_user_username \
20260331053518_add_meetup_visibility \
20260331061812_add_user_username
do
echo "Marking as applied: $migration_name"
# Ignore failures when already recorded or already baselined
dotenv -e ../.env -e .env -- prisma migrate resolve --applied "$migration_name" || true
done
echo "Applying any pending migrations..."
dotenv -e ../.env -e .env -- prisma migrate deploy
+68
View File
@@ -0,0 +1,68 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
router.use(requireAuth, requires('board.manage'));
router.get('/', async (_req: Request, res: Response) => {
try {
const messages = await prisma.boardMessage.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(messages);
} catch (err) {
console.error('Admin list board messages error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Toggle active <-> hidden */
router.post('/:id/hide', async (req: Request, res: Response) => {
try {
const rawId = req.params.id;
const id = Array.isArray(rawId) ? rawId[0] : rawId;
const msg = await prisma.boardMessage.findUnique({ where: { id } });
if (!msg) {
res.status(404).json({ error: 'Not found' });
return;
}
if (msg.status === 'deleted') {
res.status(400).json({ error: 'Message is deleted' });
return;
}
const next = msg.status === 'hidden' ? 'active' : 'hidden';
const updated = await prisma.boardMessage.update({
where: { id: msg.id },
data: { status: next },
});
res.json(updated);
} catch (err) {
console.error('Admin hide board message error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Soft-delete */
router.delete('/:id', async (req: Request, res: Response) => {
try {
const rawId = req.params.id;
const id = Array.isArray(rawId) ? rawId[0] : rawId;
const msg = await prisma.boardMessage.findUnique({ where: { id } });
if (!msg) {
res.status(404).json({ error: 'Not found' });
return;
}
const updated = await prisma.boardMessage.update({
where: { id: msg.id },
data: { status: 'deleted' },
});
res.json(updated);
} catch (err) {
console.error('Admin delete board message error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+150
View File
@@ -0,0 +1,150 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { generateApiKey } from '../services/apiKeys';
import { isValidPermissionKey } from '../constants/permissions';
const router = Router();
function serialize(key: {
id: string;
name: string;
prefix: string;
permissions: string;
createdByPubkey: string;
lastUsedAt: Date | null;
revokedAt: Date | null;
createdAt: Date;
}) {
let permissions: string[] = [];
try {
const parsed = JSON.parse(key.permissions);
if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string');
} catch {
permissions = [];
}
return {
id: key.id,
name: key.name,
prefix: key.prefix,
permissions,
createdByPubkey: key.createdByPubkey,
lastUsedAt: key.lastUsedAt,
revokedAt: key.revokedAt,
createdAt: key.createdAt,
};
}
router.get(
'/',
requireAuth,
requires('api_keys.manage'),
async (_req: Request, res: Response) => {
try {
const keys = await prisma.apiKey.findMany({ orderBy: { createdAt: 'desc' } });
res.json(keys.map(serialize));
} catch (err) {
console.error('List API keys error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/',
requireAuth,
requires('api_keys.manage'),
async (req: Request, res: Response) => {
try {
const { name, permissions } = req.body as { name?: string; permissions?: unknown };
const trimmedName = typeof name === 'string' ? name.trim() : '';
if (!trimmedName) {
res.status(400).json({ error: 'name is required' });
return;
}
if (trimmedName.length > 100) {
res.status(400).json({ error: 'name must be 100 characters or fewer' });
return;
}
if (!Array.isArray(permissions) || permissions.length === 0) {
res.status(400).json({ error: 'At least one permission is required' });
return;
}
const requested = [...new Set(permissions.filter((p): p is string => typeof p === 'string'))];
const invalid = requested.filter((p) => !isValidPermissionKey(p));
if (invalid.length > 0) {
res.status(400).json({ error: `Unknown permissions: ${invalid.join(', ')}` });
return;
}
// A key can never grant more than its creator holds. SuperAdmins hold all.
const caller = req.access!;
if (!caller.isSuperAdmin) {
const exceeded = requested.filter((p) => !caller.permissions.has(p));
if (exceeded.length > 0) {
res
.status(403)
.json({ error: `You cannot grant permissions you do not hold: ${exceeded.join(', ')}` });
return;
}
}
const { rawKey, prefix, keyHash } = generateApiKey();
const created = await prisma.apiKey.create({
data: {
name: trimmedName,
prefix,
keyHash,
permissions: JSON.stringify(requested),
createdByPubkey: req.user!.pubkey,
},
});
// The raw key is returned exactly once and never stored in plaintext.
res.status(201).json({ ...serialize(created), key: rawKey });
} catch (err) {
console.error('Create API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requires('api_keys.manage'),
async (req: Request, res: Response) => {
try {
const idRaw = req.params.id;
const id = typeof idRaw === 'string' ? idRaw : Array.isArray(idRaw) ? idRaw[0] : '';
if (!id) {
res.status(400).json({ error: 'id is required' });
return;
}
const existing = await prisma.apiKey.findUnique({ where: { id } });
if (!existing) {
res.status(404).json({ error: 'API key not found' });
return;
}
if (existing.revokedAt) {
res.json(serialize(existing));
return;
}
const revoked = await prisma.apiKey.update({
where: { id },
data: { revokedAt: new Date() },
});
res.json(serialize(revoked));
} catch (err) {
console.error('Revoke API key error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+44 -7
View File
@@ -1,6 +1,8 @@
import { Router, Request, Response } from 'express';
import { authService } from '../services/auth';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import { normalizePubkey } from '../services/pubkey';
const router = Router();
@@ -34,20 +36,55 @@ router.post('/verify', async (req: Request, res: Response) => {
return;
}
const role = await authService.getRole(pubkey);
// Ensure a user row exists, but never overwrite an existing role on login.
// The role is managed only through role assignment, and SuperAdmin is env-sourced.
// Store the pubkey as hex so the same identity is never duplicated as npub.
const hexPubkey = normalizePubkey(pubkey);
const dbUser = await prisma.user.upsert({
where: { pubkey },
update: { role },
create: { pubkey, role },
where: { pubkey: hexPubkey },
update: {},
create: { pubkey: hexPubkey },
});
const token = authService.generateToken(pubkey, role);
res.json({ token, user: { pubkey, role, username: dbUser.username ?? undefined } });
const access = await authService.resolveAccess(pubkey);
// The token carries a display role for convenience only. Authorization is
// always resolved live from the env list and the database.
const token = authService.generateToken(pubkey, access.role);
res.json({
token,
user: {
pubkey,
role: access.role,
isSuperAdmin: access.isSuperAdmin,
permissions: [...access.permissions],
username: dbUser.username ?? undefined,
},
});
} catch (err) {
console.error('Verify error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Returns the current user's live effective role and permission set, for
// frontend gating. Identity comes from the JWT, authorization is resolved fresh.
router.get('/me', requireAuth, async (req: Request, res: Response) => {
try {
const pubkey = req.user!.pubkey;
const access = await authService.resolveAccess(pubkey);
const dbUser = await prisma.user.findUnique({ where: { pubkey } });
res.json({
pubkey,
role: access.role,
isSuperAdmin: access.isSuperAdmin,
permissions: [...access.permissions],
username: dbUser?.username ?? undefined,
});
} catch (err) {
console.error('Me error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+13 -2
View File
@@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
@@ -90,8 +91,14 @@ router.get('/ics', async (_req: Request, res: Response) => {
const cutoff = sevenDaysAgo.toISOString().slice(0, 10);
const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } },
// Public subscription feed — never expose HIDDEN or unpublished meetups.
where: {
date: { gte: cutoff },
visibility: 'PUBLIC',
status: 'PUBLISHED',
},
orderBy: { date: 'asc' },
include: { organizer: true },
});
const siteUrl = (process.env.FRONTEND_URL || 'https://belgianbitcoinembassy.org').replace(
@@ -129,8 +136,11 @@ router.get('/ics', async (_req: Request, res: Response) => {
lines.push(fold(`LOCATION:${escapeIcs(meetup.location)}`));
}
lines.push(fold(`URL:${eventUrl}`));
const orgName = meetup.organizer?.name || 'Belgian Bitcoin Embassy';
lines.push(
'ORGANIZER;CN=Belgian Bitcoin Embassy:mailto:info@belgianbitcoinembassy.org'
fold(
`ORGANIZER;CN=${escapeIcs(orgName)}:mailto:info@belgianbitcoinembassy.org`
)
);
// 15-minute reminder alarm
lines.push('BEGIN:VALARM');
@@ -156,6 +166,7 @@ router.get('/ics', async (_req: Request, res: Response) => {
res.send(icsBody);
} catch (err) {
console.error('Calendar ICS error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -19,7 +19,7 @@ router.get('/', async (_req: Request, res: Response) => {
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const { name, slug, sortOrder } = req.body;
@@ -47,7 +47,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
@@ -80,7 +80,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('categories.manage'),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
+6 -6
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -23,7 +23,7 @@ router.get('/', async (req: Request, res: Response) => {
router.get(
'/all',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (_req: Request, res: Response) => {
try {
const faqs = await prisma.faq.findMany({
@@ -41,7 +41,7 @@ router.get(
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const { question, answer, showOnHomepage } = req.body;
@@ -75,7 +75,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
@@ -107,7 +107,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
@@ -129,7 +129,7 @@ router.delete(
router.post(
'/reorder',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('faq.manage'),
async (req: Request, res: Response) => {
try {
const { items } = req.body as { items: { id: string; order: number }[] };
+45 -28
View File
@@ -5,7 +5,7 @@ import fs from 'fs';
import { ulid } from 'ulid';
import slugify from 'slugify';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const REPO_ROOT = path.resolve(__dirname, '../../..');
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH
@@ -18,12 +18,23 @@ function ensureStorageDir() {
fs.mkdirSync(STORAGE_PATH, { recursive: true });
}
// Stream uploads to disk — buffering up to 100MB in RAM OOMs this small VPS.
// Filename is a ULID so the original client name never touches the filesystem.
const upload = multer({
storage: multer.memoryStorage(),
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
ensureStorageDir();
cb(null, STORAGE_PATH);
},
filename: (_req, _file, cb) => {
cb(null, ulid());
},
}),
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
});
const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
// SVG deliberately excluded: served as image/svg+xml it is executable markup (stored XSS).
const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const VIDEO_MIMES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES];
@@ -43,7 +54,7 @@ const router = Router();
router.post(
'/upload',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.upload'),
upload.single('file'),
async (req: Request, res: Response) => {
try {
@@ -54,24 +65,23 @@ router.post(
}
if (!ALLOWED_MIMES.includes(file.mimetype)) {
// Disk storage already wrote the rejected blob — remove it.
if (file.path) fs.unlink(file.path, () => {});
res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` });
return;
}
const mediaType = getMediaType(file.mimetype);
if (!mediaType) {
if (file.path) fs.unlink(file.path, () => {});
res.status(400).json({ error: 'Could not determine media type' });
return;
}
const id = ulid();
// multer.diskStorage already wrote the blob under a ULID filename.
const id = file.filename;
const slug = makeSlug(file.originalname);
ensureStorageDir();
const filePath = path.join(STORAGE_PATH, id);
fs.writeFileSync(filePath, file.buffer);
const metaPath = path.join(STORAGE_PATH, `${id}.json`);
fs.writeFileSync(metaPath, JSON.stringify({
mimeType: file.mimetype,
@@ -79,23 +89,30 @@ router.post(
size: file.size,
}));
const media = await prisma.media.create({
data: {
id,
slug,
type: mediaType,
mimeType: file.mimetype,
size: file.size,
originalFilename: file.originalname,
uploadedBy: req.user!.pubkey,
},
});
try {
const media = await prisma.media.create({
data: {
id,
slug,
type: mediaType,
mimeType: file.mimetype,
size: file.size,
originalFilename: file.originalname,
uploadedBy: req.user!.pubkey,
},
});
res.status(201).json({
id: media.id,
slug: media.slug,
url: `/media/${media.id}`,
});
res.status(201).json({
id: media.id,
slug: media.slug,
url: `/media/${media.id}`,
});
} catch (dbErr) {
// Roll back the on-disk blob if the DB insert fails.
if (file.path) fs.unlink(file.path, () => {});
if (fs.existsSync(metaPath)) fs.unlink(metaPath, () => {});
throw dbErr;
}
} catch (err) {
console.error('Upload media error:', err);
res.status(500).json({ error: 'Internal server error' });
@@ -142,7 +159,7 @@ router.get('/:id', async (req: Request, res: Response) => {
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.upload'),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
@@ -180,7 +197,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('gallery.delete'),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
+120 -13
View File
@@ -1,9 +1,47 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
import { DEFAULT_ORGANIZER_SLUG } from '../constants/organizer';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const router = Router();
const meetupInclude = { organizer: true } as const;
// Resolves access for an optional Bearer token (JWT or API key) without
// rejecting unauthenticated callers, so public listings stay open while staff
// can still see HIDDEN/DRAFT meetups. Mirrors the pattern in api/posts.ts.
async function resolveOptionalAccess(req: Request): Promise<ResolvedAccess | null> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) return null;
const token = header.slice(7);
try {
if (looksLikeApiKey(token)) {
return await resolveApiKey(token);
}
const payload = jwt.verify(token, JWT_SECRET) as { pubkey: string };
return await authService.resolveAccess(payload.pubkey);
} catch {
return null;
}
}
// True when the requester may see non-public meetups (HIDDEN / drafts).
async function canViewHiddenMeetups(req: Request): Promise<boolean> {
const access = await resolveOptionalAccess(req);
return (
!!access &&
(access.isSuperAdmin ||
access.permissions.has('events.edit') ||
access.permissions.has('events.create'))
);
}
function incrementTitle(title: string): string {
const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) {
@@ -13,22 +51,43 @@ function incrementTitle(title: string): string {
return `${title} (copy)`;
}
async function resolveOrganizerIdForCreate(organizerId: unknown): Promise<string | null> {
if (typeof organizerId === 'string' && organizerId.trim()) {
return organizerId;
}
const def = await prisma.organizer.findUnique({ where: { slug: DEFAULT_ORGANIZER_SLUG } });
return def?.id ?? null;
}
router.get('/', async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
const admin = req.query.admin === 'true';
const where: any = {};
// `admin=true` reveals HIDDEN meetups, so honor it only for authorized staff.
const admin = req.query.admin === 'true' && (await canViewHiddenMeetups(req));
const organizerSlug = req.query.organizerSlug as string | undefined;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (!admin) where.visibility = 'PUBLIC';
if (organizerSlug) {
const org = await prisma.organizer.findUnique({ where: { slug: organizerSlug } });
if (!org) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
where.organizerId = org.id;
}
const meetups = await prisma.meetup.findMany({
where,
orderBy: { date: 'asc' },
include: meetupInclude,
});
res.json(meetups);
} catch (err) {
console.error('List meetups error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -37,6 +96,7 @@ router.get('/:id', async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
include: meetupInclude,
});
if (!meetup) {
@@ -44,9 +104,20 @@ router.get('/:id', async (req: Request, res: Response) => {
return;
}
// Don't leak HIDDEN / unpublished meetups to the public; treat as not-found.
const staff = await canViewHiddenMeetups(req);
if (
!staff &&
(meetup.visibility !== 'PUBLIC' || meetup.status !== 'PUBLISHED')
) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
res.json(meetup);
} catch (err) {
console.error('Get meetup error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
@@ -54,11 +125,22 @@ router.get('/:id', async (req: Request, res: Response) => {
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('events.create'),
async (req: Request, res: Response) => {
try {
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
const {
title,
description,
date,
time,
location,
link,
status,
featured,
imageId,
visibility,
organizerId,
} = req.body;
if (!title || !description || !date || !time || !location) {
res
@@ -67,6 +149,12 @@ router.post(
return;
}
const resolvedOrganizerId = await resolveOrganizerIdForCreate(organizerId);
if (!resolvedOrganizerId) {
res.status(500).json({ error: 'Default organizer is not configured' });
return;
}
const meetup = await prisma.meetup.create({
data: {
title,
@@ -79,7 +167,9 @@ router.post(
status: status || 'DRAFT',
featured: featured || false,
visibility: visibility || 'PUBLIC',
organizerId: resolvedOrganizerId,
},
include: meetupInclude,
});
res.status(201).json(meetup);
@@ -93,7 +183,7 @@ router.post(
router.post(
'/bulk',
requireAuth,
requireRole(['ADMIN']),
requires('events.edit'),
async (req: Request, res: Response) => {
try {
const { action, ids } = req.body as { action: string; ids: string[] };
@@ -134,7 +224,9 @@ router.post(
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
organizerId: m.organizerId,
},
include: meetupInclude,
})
)
);
@@ -153,7 +245,7 @@ router.post(
router.post(
'/:id/duplicate',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('events.create'),
async (req: Request, res: Response) => {
try {
const original = await prisma.meetup.findUnique({ where: { id: req.params.id as string } });
@@ -174,7 +266,9 @@ router.post(
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
organizerId: original.organizerId,
},
include: meetupInclude,
});
res.status(201).json(duplicate);
@@ -188,7 +282,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('events.edit'),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
@@ -199,10 +293,21 @@ router.patch(
return;
}
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
const {
title,
description,
date,
time,
location,
link,
status,
featured,
imageId,
visibility,
organizerId,
} = req.body;
const updateData: any = {};
const updateData: Record<string, unknown> = {};
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (date !== undefined) updateData.date = date;
@@ -213,10 +318,12 @@ router.patch(
if (featured !== undefined) updateData.featured = featured;
if (imageId !== undefined) updateData.imageId = imageId;
if (visibility !== undefined) updateData.visibility = visibility;
if (organizerId !== undefined) updateData.organizerId = organizerId;
const updated = await prisma.meetup.update({
where: { id: req.params.id as string },
data: updateData,
include: meetupInclude,
});
res.json(updated);
@@ -230,7 +337,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('events.delete'),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
+359
View File
@@ -0,0 +1,359 @@
import { Router, Request, Response } from 'express';
import { timingSafeEqual } from 'crypto';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import {
createIncomingInvoice,
verifyIncomingPaymentPaid,
getPublicPaymentStatus,
} from '../services/lnbits';
const router = Router();
const MAX_CONTENT = 300;
const MAX_NAME = 80;
const MESSAGE_PRICE_SATS = Math.max(1, parseInt(process.env.MESSAGE_PRICE_SATS || '1000', 10) || 1000);
const WEBHOOK_BASE = (process.env.WEBHOOK_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3000').replace(
/\/$/,
''
);
const WEBHOOK_SECRET = process.env.LNBITS_WEBHOOK_SECRET || '';
function sanitizeText(s: string, max: number): string {
const t = s
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/<[^>]*>/g, '')
.trim();
return t.length > max ? t.slice(0, max) : t;
}
/** LNbits sends `json=payment.json()` where `.json()` returns a Pydantic JSON
* string — httpx double-encodes it, so Express may parse the body as a plain
* string instead of an object. Unwrap up to two layers of JSON encoding. */
function normalizeWebhookBody(raw: unknown): unknown {
let v = raw;
for (let i = 0; i < 2 && typeof v === 'string'; i++) {
try { v = JSON.parse(v); } catch { break; }
}
return v;
}
const HEX64 = /^[a-f0-9]{64}$/i;
function extractPaymentHash(body: unknown): string | null {
const o = normalizeWebhookBody(body);
if (!o || typeof o !== 'object') return null;
const r = o as Record<string, unknown>;
for (const key of ['payment_hash', 'paymentHash', 'checking_id']) {
const v = r[key];
if (typeof v === 'string' && HEX64.test(v)) return v.toLowerCase();
}
const p = r.payment;
if (p && typeof p === 'object') {
const nested = p as Record<string, unknown>;
for (const key of ['payment_hash', 'paymentHash', 'checking_id']) {
const v = nested[key];
if (typeof v === 'string' && HEX64.test(v)) return v.toLowerCase();
}
}
return null;
}
function verifyWebhookSecret(req: Request): boolean {
if (!WEBHOOK_SECRET) return true;
const h = req.headers['x-bbe-webhook-secret'];
if (typeof h === 'string' && safeEqualStr(h, WEBHOOK_SECRET)) return true;
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
const t = auth.slice(7);
if (safeEqualStr(t, WEBHOOK_SECRET)) return true;
}
const q = req.query.token;
const token = typeof q === 'string' ? q : Array.isArray(q) ? q[0] : '';
if (typeof token === 'string' && token.length > 0 && safeEqualStr(token, WEBHOOK_SECRET)) {
return true;
}
return false;
}
function safeEqualStr(a: string, b: string): boolean {
try {
const ab = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
} catch {
return false;
}
}
/** Public: board config for the frontend */
router.get('/config', (_req: Request, res: Response) => {
res.json({
priceSats: MESSAGE_PRICE_SATS,
bbeZapPubkey: process.env.BOARD_ZAP_PUBKEY || null,
bbeZapAddress: process.env.BOARD_ZAP_LN_ADDRESS || null,
});
});
/** Create Lightning invoice for a new message (no BoardMessage row yet). */
router.post('/invoice', async (req: Request, res: Response) => {
try {
const { content, name, pubkey, profilePic, postAsAnon } = req.body as Record<string, unknown>;
if (typeof content !== 'string') {
res.status(400).json({ error: 'content is required' });
return;
}
const cleanContent = sanitizeText(content, MAX_CONTENT);
if (!cleanContent) {
res.status(400).json({ error: 'content is empty' });
return;
}
let guestName: string | null = null;
let authorPubkey: string | null = null;
let pic: string | null = null;
const pk = typeof pubkey === 'string' && /^[a-f0-9]{64}$/i.test(pubkey) ? pubkey.toLowerCase() : null;
const asAnon = postAsAnon === true;
if (pk && !asAnon) {
authorPubkey = pk;
if (typeof profilePic === 'string' && profilePic.length > 0 && profilePic.length < 2048) {
pic = profilePic.slice(0, 2048);
}
const n = typeof name === 'string' ? sanitizeText(name, MAX_NAME) : '';
guestName = n || null;
} else {
const n = typeof name === 'string' ? sanitizeText(name, MAX_NAME) : '';
guestName = n || null;
}
const webhookPath = WEBHOOK_SECRET
? `/api/messages/webhook?token=${encodeURIComponent(WEBHOOK_SECRET)}`
: '/api/messages/webhook';
const webhookUrl = `${WEBHOOK_BASE}${webhookPath}`;
const { payment_hash, payment_request, checking_id } = await createIncomingInvoice({
amountSats: MESSAGE_PRICE_SATS,
memo: 'BBE message board',
webhookUrl,
});
await prisma.boardInvoicePending.upsert({
where: { paymentHash: payment_hash },
create: {
paymentHash: payment_hash,
content: cleanContent,
guestName,
pubkey: authorPubkey,
profilePic: pic,
},
update: {
content: cleanContent,
guestName,
pubkey: authorPubkey,
profilePic: pic,
},
});
res.json({
payment_request,
checking_id,
payment_hash,
});
} catch (err) {
console.error('Board invoice error:', err);
const msg = err instanceof Error ? err.message : 'Internal server error';
if (msg.includes('LNBITS_API_KEY')) {
res.status(503).json({ error: 'Lightning payments are not configured' });
return;
}
res.status(500).json({ error: msg });
}
});
/**
* Shared logic: verify payment on LNbits, look up pending row, create BoardMessage.
* Returns { ok, duplicate?, ignored?, error? }.
*/
async function promotePendingToMessage(paymentHash: string): Promise<{
ok: boolean;
duplicate?: boolean;
ignored?: string;
error?: string;
}> {
const existing = await prisma.boardMessage.findUnique({ where: { paymentHash } });
if (existing) {
await prisma.boardInvoicePending.deleteMany({ where: { paymentHash } });
return { ok: true, duplicate: true };
}
const verified = await verifyIncomingPaymentPaid(paymentHash);
if (!verified.paid) {
return { ok: true, ignored: 'not_paid' };
}
const expectedMsat = MESSAGE_PRICE_SATS * 1000;
if (verified.amountMsat != null && verified.amountMsat < expectedMsat) {
console.warn('Board: amount mismatch', paymentHash, verified.amountMsat);
return { ok: true, ignored: 'amount' };
}
const pending = await prisma.boardInvoicePending.findUnique({ where: { paymentHash } });
if (!pending) {
return { ok: true, ignored: 'no_pending' };
}
const authorName = pending.pubkey
? pending.guestName && pending.guestName.length > 0
? pending.guestName
: 'Nostr user'
: pending.guestName && pending.guestName.length > 0
? pending.guestName
: 'anon';
try {
await prisma.$transaction([
prisma.boardMessage.create({
data: {
paymentHash,
content: pending.content,
authorName,
pubkey: pending.pubkey,
profilePic: pending.profilePic,
satsPaid: MESSAGE_PRICE_SATS,
status: 'active',
},
}),
prisma.boardInvoicePending.delete({ where: { paymentHash } }),
]);
} catch (e: unknown) {
const code = e && typeof e === 'object' && 'code' in e ? (e as { code: string }).code : '';
if (code === 'P2002') {
await prisma.boardInvoicePending.deleteMany({ where: { paymentHash } });
return { ok: true, duplicate: true };
}
throw e;
}
return { ok: true };
}
/** LNbits calls this when an invoice is paid */
router.post('/webhook', async (req: Request, res: Response) => {
try {
if (!verifyWebhookSecret(req)) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const paymentHash = extractPaymentHash(req.body);
if (!paymentHash) {
const preview = typeof req.body === 'string' ? req.body.slice(0, 200) : JSON.stringify(req.body).slice(0, 200);
console.warn('Board webhook: could not extract payment_hash', typeof req.body, preview);
res.status(200).json({ ok: false, error: 'missing payment_hash' });
return;
}
const result = await promotePendingToMessage(paymentHash);
res.json(result);
} catch (err) {
console.error('Board webhook error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Client-side reconciliation: verify payment and create message if webhook was missed. */
router.post('/confirm/:paymentHash', async (req: Request, res: Response) => {
try {
const raw = req.params.paymentHash;
const paymentHash = Array.isArray(raw) ? raw[0] : raw;
if (!paymentHash || !HEX64.test(paymentHash)) {
res.status(400).json({ error: 'invalid payment_hash' });
return;
}
const result = await promotePendingToMessage(paymentHash.toLowerCase());
res.json(result);
} catch (err) {
console.error('Board confirm error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Poll payment status (server-side LNbits public API — avoids CORS). */
router.get('/payment/:paymentHash/status', async (req: Request, res: Response) => {
try {
const raw = req.params.paymentHash;
const paymentHash = Array.isArray(raw) ? raw[0] : raw;
if (!paymentHash || !/^[a-f0-9]{64}$/i.test(paymentHash)) {
res.status(400).json({ error: 'invalid payment_hash' });
return;
}
const hash = paymentHash.toLowerCase();
const paid = await getPublicPaymentStatus(hash);
const message = await prisma.boardMessage.findUnique({
where: { paymentHash: hash },
select: { id: true },
});
res.json({ paid: paid || !!message, messageCreated: !!message });
} catch (err) {
console.error('Board payment status error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Public list: active messages only */
router.get('/', async (_req: Request, res: Response) => {
try {
const messages = await prisma.boardMessage.findMany({
where: { status: 'active' },
orderBy: { createdAt: 'desc' },
select: {
id: true,
paymentHash: true,
content: true,
authorName: true,
pubkey: true,
profilePic: true,
satsPaid: true,
likeCount: true,
createdAt: true,
},
});
res.json(messages);
} catch (err) {
console.error('List board messages error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** Like (Nostr-attributed messages only; logged-in users) */
router.post('/:id/like', requireAuth, async (req: Request, res: Response) => {
try {
const rawId = req.params.id;
const id = Array.isArray(rawId) ? rawId[0] : rawId;
const msg = await prisma.boardMessage.findUnique({ where: { id } });
if (!msg || msg.status !== 'active') {
res.status(404).json({ error: 'Message not found' });
return;
}
if (!msg.pubkey) {
res.status(400).json({ error: 'Likes are only for Nostr-attributed messages' });
return;
}
const updated = await prisma.boardMessage.update({
where: { id: msg.id },
data: { likeCount: { increment: 1 } },
select: { likeCount: true },
});
res.json({ likeCount: updated.likeCount });
} catch (err) {
console.error('Board like error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;
+7 -7
View File
@@ -1,13 +1,13 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
router.get(
'/hidden',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (_req: Request, res: Response) => {
try {
const hidden = await prisma.hiddenContent.findMany({
@@ -24,7 +24,7 @@ router.get(
router.post(
'/hide',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const { nostrEventId, reason } = req.body;
@@ -52,7 +52,7 @@ router.post(
router.delete(
'/unhide/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const item = await prisma.hiddenContent.findUnique({
@@ -75,7 +75,7 @@ router.delete(
router.get(
'/blocked',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (_req: Request, res: Response) => {
try {
const blocked = await prisma.blockedPubkey.findMany({
@@ -92,7 +92,7 @@ router.get(
router.post(
'/block',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const { pubkey, reason } = req.body;
@@ -120,7 +120,7 @@ router.post(
router.delete(
'/unblock/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('moderation.act'),
async (req: Request, res: Response) => {
try {
const item = await prisma.blockedPubkey.findUnique({
+4 -4
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
@@ -8,7 +8,7 @@ const router = Router();
router.post(
'/fetch',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
@@ -40,7 +40,7 @@ router.post(
router.post(
'/cache/refresh',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (_req: Request, res: Response) => {
try {
const cachedEvents = await prisma.nostrEventCache.findMany();
@@ -62,7 +62,7 @@ router.post(
router.get(
'/debug/:eventId',
requireAuth,
requireRole(['ADMIN']),
requires('nostr_tools.use'),
async (req: Request, res: Response) => {
try {
const cached = await prisma.nostrEventCache.findUnique({
+132
View File
@@ -0,0 +1,132 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import { respondIfOrganizerMigrationNeeded } from '../lib/prismaMigrationHint';
const router = Router();
router.get('/', async (_req: Request, res: Response) => {
try {
const organizers = await prisma.organizer.findMany({
orderBy: { name: 'asc' },
});
res.json(organizers);
} catch (err) {
console.error('List organizers error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/by-slug/:slug', async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { slug: req.params.slug as string },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
res.json(organizer);
} catch (err) {
console.error('Get organizer by slug error:', err);
if (respondIfOrganizerMigrationNeeded(err, res)) return;
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/',
requireAuth,
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const { name, slug } = req.body;
if (!name || !slug) {
res.status(400).json({ error: 'name and slug are required' });
return;
}
const organizer = await prisma.organizer.create({
data: { name, slug },
});
res.status(201).json(organizer);
} catch (err: any) {
if (err?.code === 'P2002') {
res.status(400).json({ error: 'An organizer with this slug already exists' });
return;
}
console.error('Create organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { id: req.params.id as string },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
const { name, slug } = req.body;
const updateData: { name?: string; slug?: string } = {};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
const updated = await prisma.organizer.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err: any) {
if (err?.code === 'P2002') {
res.status(400).json({ error: 'An organizer with this slug already exists' });
return;
}
console.error('Update organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requires('organizers.manage'),
async (req: Request, res: Response) => {
try {
const organizer = await prisma.organizer.findUnique({
where: { id: req.params.id as string },
include: { _count: { select: { meetups: true } } },
});
if (!organizer) {
res.status(404).json({ error: 'Organizer not found' });
return;
}
if (organizer._count.meetups > 0) {
res.status(400).json({
error: `Cannot delete organizer: ${organizer._count.meetups} event(s) still reference it`,
});
return;
}
await prisma.organizer.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete organizer error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+125 -58
View File
@@ -1,10 +1,89 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
import { nostrService } from '../services/nostr';
import { importPostFromNostr } from '../services/postImport';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const router = Router();
// Resolves access for an optional Bearer token (JWT or API key) without
// rejecting unauthenticated callers, so listing endpoints can stay public while
// still recognizing staff who pass `?all=true`.
async function resolveOptionalAccess(req: Request): Promise<ResolvedAccess | null> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) return null;
const token = header.slice(7);
try {
if (looksLikeApiKey(token)) {
return await resolveApiKey(token);
}
const payload = jwt.verify(token, JWT_SECRET) as { pubkey: string };
return await authService.resolveAccess(payload.pubkey);
} catch {
return null;
}
}
// Returns true when the requester is authenticated and may see hidden posts.
async function canViewHiddenPosts(req: Request): Promise<boolean> {
const access = await resolveOptionalAccess(req);
return !!access && (access.isSuperAdmin || access.permissions.has('blog.draft'));
}
// Resolves a blog slug to the Nostr event id used for reactions/replies,
// whether it's an indexed post or a live NIP-19 reference. Returns null if
// neither resolves.
async function resolveEventIdForSlug(slug: string): Promise<string | null> {
const post = await prisma.post.findUnique({ where: { slug } });
if (post) return post.nostrEventId;
const event = await nostrService.resolveEventByIdentifier(slug);
return event?.id ?? null;
}
function tagValue(event: { tags?: string[][] }, name: string): string | undefined {
return event.tags?.find((t) => t[0] === name)?.[1];
}
// Shapes a live Nostr longform event into the same JSON the frontend expects
// from an indexed Post, so naddr/nevent/note links render through the regular
// post template instead of 404ing. `identifier` is the original URL segment and
// becomes the slug so canonical URLs and reaction/reply lookups stay stable.
function buildPostShapeFromEvent(
event: { id: string; pubkey: string; content: string; created_at: number; tags?: string[][] },
identifier: string
) {
const title = tagValue(event, 'title') || 'Untitled';
const summary = tagValue(event, 'summary') || null;
const image = tagValue(event, 'image') || null;
const publishedAtSec = Number(tagValue(event, 'published_at')) || event.created_at;
const categories = (event.tags || [])
.filter((t) => t[0] === 't' && t[1])
.map((t) => ({ category: { id: t[1], name: t[1], slug: t[1] } }));
return {
id: event.id,
nostrEventId: event.id,
naddr: identifier.startsWith('naddr') ? identifier : null,
title,
slug: identifier,
content: event.content || '',
excerpt: summary,
image,
authorPubkey: event.pubkey,
authorName: null,
featured: false,
visible: true,
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
createdAt: new Date(event.created_at * 1000).toISOString(),
categories,
};
}
router.get('/', async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
@@ -12,7 +91,10 @@ router.get('/', async (req: Request, res: Response) => {
const category = req.query.category as string | undefined;
const skip = (page - 1) * limit;
const where: any = { visible: true };
const includeHidden = req.query.all === 'true' && (await canViewHiddenPosts(req));
const where: any = {};
if (!includeHidden) where.visible = true;
if (category) {
where.categories = {
some: { category: { slug: category } },
@@ -34,6 +116,7 @@ router.get('/', async (req: Request, res: Response) => {
res.json({
posts,
total,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
} catch (err) {
@@ -44,19 +127,31 @@ router.get('/', async (req: Request, res: Response) => {
router.get('/:slug', async (req: Request, res: Response) => {
try {
const slug = req.params.slug as string;
const post = await prisma.post.findUnique({
where: { slug: req.params.slug as string },
where: { slug },
include: {
categories: { include: { category: true } },
},
});
if (!post) {
res.status(404).json({ error: 'Post not found' });
if (post) {
// Indexed posts store an empty body (the canonical copy lives on Nostr);
// the frontend hydrates the body live from relays. Return as-is.
res.json(post);
return;
}
res.json(post);
// Not indexed: resolve the slug as a live NIP-19 reference so the page has
// real metadata (title, summary, image) for SEO instead of "Post Not
// Found". The frontend still fetches the body from relays for display.
const event = await nostrService.resolveEventByIdentifier(slug);
if (event) {
res.json(buildPostShapeFromEvent(event, slug));
return;
}
res.status(404).json({ error: 'Post not found' });
} catch (err) {
console.error('Get post error:', err);
res.status(500).json({ error: 'Internal server error' });
@@ -66,57 +161,27 @@ router.get('/:slug', async (req: Request, res: Response) => {
router.post(
'/import',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('blog.draft'),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
if (!eventId && !naddr) {
res.status(400).json({ error: 'eventId or naddr is required' });
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags } = req.body;
if (!nostrEventId || !title || !authorPubkey) {
res.status(400).json({ error: 'nostrEventId, title, and authorPubkey are required' });
return;
}
let event: any = null;
if (naddr) {
event = await nostrService.fetchLongformEvent(naddr);
} else if (eventId) {
event = await nostrService.fetchEvent(eventId);
}
if (!event) {
res.status(404).json({ error: 'Event not found on relays' });
return;
}
const titleTag = event.tags?.find((t: string[]) => t[0] === 'title');
const title = titleTag?.[1] || 'Untitled';
const slugBase = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const slug = `${slugBase}-${event.id.slice(0, 8)}`;
const excerpt = event.content.slice(0, 200).replace(/[#*_\n]/g, '').trim();
const post = await prisma.post.upsert({
where: { nostrEventId: event.id },
update: {
title,
content: event.content,
excerpt,
},
create: {
nostrEventId: event.id,
title,
slug,
content: event.content,
excerpt,
authorPubkey: event.pubkey,
publishedAt: new Date(event.created_at * 1000),
},
const result = await importPostFromNostr({
nostrEventId,
naddr,
title,
excerpt,
authorPubkey,
publishedAt,
tags,
});
res.json(post);
res.json(result);
} catch (err) {
console.error('Import post error:', err);
res.status(500).json({ error: 'Internal server error' });
@@ -127,7 +192,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('blog.draft'),
async (req: Request, res: Response) => {
try {
const { title, slug, excerpt, featured, visible, categories } = req.body;
@@ -177,13 +242,14 @@ router.patch(
router.get('/:slug/reactions', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
if (!post) {
const slug = req.params.slug as string;
const eventId = await resolveEventIdForSlug(slug);
if (!eventId) {
res.status(404).json({ error: 'Post not found' });
return;
}
const reactions = await nostrService.fetchReactions(post.nostrEventId);
const reactions = await nostrService.fetchReactions(eventId);
res.json({ count: reactions.length, reactions });
} catch (err) {
console.error('Get reactions error:', err);
@@ -193,14 +259,15 @@ router.get('/:slug/reactions', async (req: Request, res: Response) => {
router.get('/:slug/replies', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
if (!post) {
const slug = req.params.slug as string;
const eventId = await resolveEventIdForSlug(slug);
if (!eventId) {
res.status(404).json({ error: 'Post not found' });
return;
}
const [replies, hiddenContent, blockedPubkeys] = await Promise.all([
nostrService.fetchReplies(post.nostrEventId),
nostrService.fetchReplies(eventId),
prisma.hiddenContent.findMany({ select: { nostrEventId: true } }),
prisma.blockedPubkey.findMany({ select: { pubkey: true } }),
]);
@@ -222,7 +289,7 @@ router.get('/:slug/replies', async (req: Request, res: Response) => {
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('blog.delete'),
async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { id: req.params.id as string } });
+22 -6
View File
@@ -1,14 +1,30 @@
import { Router, Request, Response } from 'express';
import { SimplePool } from 'nostr-tools';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
router.get(
'/public',
async (_req: Request, res: Response) => {
try {
const relays = await prisma.relay.findMany({
where: { active: true },
orderBy: { priority: 'asc' },
});
res.json({ relays: relays.map((r) => r.url) });
} catch (err) {
console.error('Public relays error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (_req: Request, res: Response) => {
try {
const relays = await prisma.relay.findMany({
@@ -25,7 +41,7 @@ router.get(
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const { url, priority } = req.body;
@@ -52,7 +68,7 @@ router.post(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
@@ -85,7 +101,7 @@ router.patch(
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
@@ -108,7 +124,7 @@ router.delete(
router.post(
'/:id/test',
requireAuth,
requireRole(['ADMIN']),
requires('relays.manage'),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
+128
View File
@@ -0,0 +1,128 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requires } from '../middleware/auth';
import {
PERMISSIONS,
ASSIGNABLE_ROLES,
isAssignableRole,
isValidPermissionKey,
} from '../constants/permissions';
const router = Router();
// Returns the full permission registry plus the assignable roles. Adding a key
// to the registry surfaces it here automatically.
router.get(
'/permissions',
requireAuth,
requires('roles.edit_permissions'),
async (_req: Request, res: Response) => {
res.json({
permissions: PERMISSIONS,
roles: ASSIGNABLE_ROLES,
});
}
);
// Returns each assignable role with its current permission set.
router.get(
'/roles',
requireAuth,
requires('roles.edit_permissions'),
async (_req: Request, res: Response) => {
try {
const rows = await prisma.rolePermission.findMany();
const byRole: Record<string, string[]> = {};
for (const role of ASSIGNABLE_ROLES) byRole[role] = [];
for (const row of rows) {
if (byRole[row.role]) byRole[row.role].push(row.permission);
}
res.json({
roles: ASSIGNABLE_ROLES.map((role) => ({ role, permissions: byRole[role] })),
});
} catch (err) {
console.error('List roles error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Replaces a role's permission set. Validates the role and every key, prevents
// privilege escalation, and prevents the caller from locking themselves out.
router.put(
'/roles/:role/permissions',
requireAuth,
requires('roles.edit_permissions'),
async (req: Request, res: Response) => {
try {
const roleRaw = req.params.role;
const role = typeof roleRaw === 'string' ? roleRaw : Array.isArray(roleRaw) ? roleRaw[0] : '';
if (!isAssignableRole(role)) {
res.status(400).json({ error: 'Invalid role' });
return;
}
const { permissions } = req.body as { permissions?: unknown };
if (!Array.isArray(permissions) || permissions.some((p) => typeof p !== 'string')) {
res.status(400).json({ error: 'permissions must be an array of strings' });
return;
}
const requested = [...new Set(permissions as string[])];
const unknown = requested.filter((p) => !isValidPermissionKey(p));
if (unknown.length > 0) {
res.status(400).json({ error: `Unknown permission keys: ${unknown.join(', ')}` });
return;
}
const caller = req.access!;
const newSet = new Set(requested);
const existingRows = await prisma.rolePermission.findMany({ where: { role } });
const currentSet = new Set(existingRows.map((r) => r.permission));
// No privilege escalation: any newly granted permission must be held by the
// caller. SuperAdmin holds everything and is exempt.
if (!caller.isSuperAdmin) {
const added = requested.filter((p) => !currentSet.has(p));
const escalated = added.filter((p) => !caller.permissions.has(p));
if (escalated.length > 0) {
res.status(403).json({
error: `You cannot grant permissions you do not hold: ${escalated.join(', ')}`,
});
return;
}
// Prevent self-lockout: when editing your own role, you cannot drop the
// permissions that keep you able to manage roles and users.
if (caller.role === role) {
const protectedKeys = ['users.assign_role', 'roles.edit_permissions'];
const lockedOut = protectedKeys.filter(
(k) => caller.permissions.has(k) && !newSet.has(k)
);
if (lockedOut.length > 0) {
res.status(403).json({
error: `You cannot remove your own ${lockedOut.join(', ')}`,
});
return;
}
}
}
// Replace the role's permission rows transactionally.
await prisma.$transaction([
prisma.rolePermission.deleteMany({ where: { role } }),
prisma.rolePermission.createMany({
data: requested.map((permission) => ({ role, permission })),
}),
]);
res.json({ role, permissions: requested });
} catch (err) {
console.error('Update role permissions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+3 -3
View File
@@ -1,6 +1,6 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
const router = Router();
@@ -18,7 +18,7 @@ const PUBLIC_SETTINGS = [
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('settings.edit'),
async (req: Request, res: Response) => {
try {
const settings = await prisma.setting.findMany();
@@ -53,7 +53,7 @@ router.get('/public', async (_req: Request, res: Response) => {
router.patch(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('settings.edit'),
async (req: Request, res: Response) => {
try {
const { key, value } = req.body;
+21 -4
View File
@@ -1,6 +1,7 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { importPostFromNostr, resolveSubmissionImport } from '../services/postImport';
const router = Router();
@@ -53,7 +54,7 @@ router.get(
router.get(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('submissions.review'),
async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
@@ -76,7 +77,7 @@ router.get(
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
requires('submissions.review'),
async (req: Request, res: Response) => {
try {
const { status, reviewNote } = req.body;
@@ -94,6 +95,22 @@ router.patch(
return;
}
// Approval publishes the referenced Nostr article to the blog. Do this
// before flipping the status so a failed import doesn't leave a submission
// marked APPROVED without a corresponding live post.
let post = null;
if (status === 'APPROVED') {
const importInput = await resolveSubmissionImport(submission);
if (!importInput) {
res.status(422).json({
error:
'Could not resolve the submitted Nostr event from relays. The post was not published, so the submission was left unchanged.',
});
return;
}
post = await importPostFromNostr(importInput);
}
const updated = await prisma.submission.update({
where: { id: req.params.id as string },
data: {
@@ -103,7 +120,7 @@ router.patch(
},
});
res.json(updated);
res.json({ submission: updated, post });
} catch (err) {
console.error('Review submission error:', err);
res.status(500).json({ error: 'Internal server error' });
+137
View File
@@ -0,0 +1,137 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
router.get(
'/',
requireAuth,
async (req: Request, res: Response) => {
try {
const relays = await prisma.userRelay.findMany({
where: { pubkey: req.user!.pubkey },
orderBy: { createdAt: 'asc' },
});
res.json(relays);
} catch (err) {
console.error('List user relays error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/',
requireAuth,
async (req: Request, res: Response) => {
try {
const { url, read, write } = req.body;
if (!url || typeof url !== 'string') {
res.status(400).json({ error: 'url is required' });
return;
}
const normalized = url.trim().replace(/\/+$/, '');
if (!normalized.startsWith('wss://') && !normalized.startsWith('ws://')) {
res.status(400).json({ error: 'Relay URL must start with wss:// or ws://' });
return;
}
const relay = await prisma.userRelay.upsert({
where: {
pubkey_url: { pubkey: req.user!.pubkey, url: normalized },
},
update: {
read: read !== undefined ? read : true,
write: write !== undefined ? write : true,
},
create: {
pubkey: req.user!.pubkey,
url: normalized,
read: read !== undefined ? read : true,
write: write !== undefined ? write : true,
},
});
res.status(201).json(relay);
} catch (err) {
console.error('Add user relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
async (req: Request, res: Response) => {
try {
const relay = await prisma.userRelay.findUnique({
where: { id: req.params.id as string },
});
if (!relay || relay.pubkey !== req.user!.pubkey) {
res.status(404).json({ error: 'Relay not found' });
return;
}
await prisma.userRelay.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete user relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/import-nip65',
requireAuth,
async (req: Request, res: Response) => {
try {
const pubkey = req.user!.pubkey;
const nip65 = await nostrService.fetchNip65Relays(pubkey);
const allUrls = new Set<string>();
const relayEntries: { url: string; read: boolean; write: boolean }[] = [];
for (const url of nip65.write) {
if (allUrls.has(url)) continue;
allUrls.add(url);
relayEntries.push({
url,
read: nip65.read.includes(url),
write: true,
});
}
for (const url of nip65.read) {
if (allUrls.has(url)) continue;
allUrls.add(url);
relayEntries.push({ url, read: true, write: false });
}
if (relayEntries.length === 0) {
res.json({ imported: 0, message: 'No NIP-65 relay list found for your pubkey' });
return;
}
let imported = 0;
for (const entry of relayEntries) {
await prisma.userRelay.upsert({
where: { pubkey_url: { pubkey, url: entry.url } },
update: { read: entry.read, write: entry.write },
create: { pubkey, url: entry.url, read: entry.read, write: entry.write },
});
imported++;
}
res.json({ imported, total: relayEntries.length });
} catch (err) {
console.error('Import NIP-65 error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;
+140 -30
View File
@@ -1,6 +1,13 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { requireAuth, requires } from '../middleware/auth';
import { isSuperadmin } from '../services/auth';
import { toHexPubkey, normalizePubkey } from '../services/pubkey';
import {
ROLE_RANK,
isAssignableRole,
type EffectiveRole,
} from '../constants/permissions';
import fs from 'fs';
import path from 'path';
@@ -36,18 +43,61 @@ function validateUsername(
return null;
}
function pickHigherRole(a: string | null, b: string | null): string | null {
const rankOf = (r: string | null): number =>
r && isAssignableRole(r) ? ROLE_RANK[r as EffectiveRole] : 0;
return rankOf(a) >= rankOf(b) ? a : b;
}
const router = Router();
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (_req: Request, res: Response) => {
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(users);
// The same person can have been stored both as hex and as npub (no
// normalization existed historically). Collapse those into a single
// identity keyed by hex so the list never shows a duplicate row.
const byHex = new Map<string, (typeof users)[number]>();
for (const u of users) {
const hex = toHexPubkey(u.pubkey) ?? u.pubkey;
const existing = byHex.get(hex);
if (!existing) {
byHex.set(hex, { ...u, pubkey: hex });
continue;
}
// Merge: prefer a stored role over none, the higher-ranked role on
// conflict, and the first available username. Keep the earliest join.
const mergedRole = pickHigherRole(existing.role, u.role);
const mergedUsername = existing.username ?? u.username;
const earliest = existing.createdAt <= u.createdAt ? existing : u;
byHex.set(hex, {
...existing,
role: mergedRole,
username: mergedUsername,
displayName: existing.displayName ?? u.displayName,
createdAt: earliest.createdAt,
pubkey: hex,
});
}
// Flag env-sourced SuperAdmins so the UI can render them locked. Their
// effective role is reported as superadmin regardless of any stored value.
const result = [...byHex.values()].map((u) => {
const superAdmin = isSuperadmin(u.pubkey);
return {
...u,
role: superAdmin ? 'superadmin' : (u.role ?? null),
isSuperAdmin: superAdmin,
};
});
res.json(result);
} catch (err) {
console.error('List users error:', err);
res.status(500).json({ error: 'Internal server error' });
@@ -55,53 +105,109 @@ router.get(
}
);
// Creates a user row from an npub or hex pubkey so admins can pre-register
// people before they have logged in. The pubkey is normalized to hex.
router.post(
'/promote',
'/',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
const { pubkey: rawPubkey } = req.body as { pubkey?: string };
if (!rawPubkey || typeof rawPubkey !== 'string') {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey },
update: { role: 'MODERATOR' },
create: { pubkey, role: 'MODERATOR' },
});
const hex = toHexPubkey(rawPubkey);
if (!hex) {
res.status(400).json({ error: 'Invalid pubkey. Provide an npub or 64-char hex key.' });
return;
}
res.json(user);
const existing = await prisma.user.findUnique({ where: { pubkey: hex } });
if (existing) {
res.status(409).json({ error: 'User already exists' });
return;
}
const user = await prisma.user.create({ data: { pubkey: hex } });
res.status(201).json({ ...user, role: user.role ?? null, isSuperAdmin: isSuperadmin(hex) });
} catch (err) {
console.error('Promote user error:', err);
console.error('Create user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/demote',
// Assigns a role to a user, replacing the old promote/demote toggle. Enforces the
// hierarchy: SuperAdmins are never modifiable here, and a non-SuperAdmin caller
// cannot assign a role at or above their own or modify anyone at or above them.
router.put(
'/:pubkey/role',
requireAuth,
requireRole(['ADMIN']),
requires('users.assign_role'),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
const pubkeyRaw = req.params.pubkey;
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const pubkey = normalizePubkey(pubkeyParam);
const user = await prisma.user.upsert({
const { role } = req.body as { role?: string | null };
// Normalize the requested role. Null, empty, or "guest" all mean remove
// any elevated role. SuperAdmin can never be assigned here.
let newRole: EffectiveRole;
if (role === null || role === undefined || role === '' || role === 'guest') {
newRole = 'guest';
} else if (isAssignableRole(role)) {
newRole = role;
} else {
res.status(400).json({ error: 'Invalid role' });
return;
}
if (isSuperadmin(pubkey)) {
res.status(403).json({ error: 'SuperAdmins cannot be modified' });
return;
}
const caller = req.access!;
const callerRank = ROLE_RANK[caller.role];
const newRank = ROLE_RANK[newRole];
const target = await prisma.user.findUnique({ where: { pubkey } });
const targetCurrent: EffectiveRole = isAssignableRole(target?.role)
? (target!.role as EffectiveRole)
: 'guest';
const targetRank = ROLE_RANK[targetCurrent];
if (!caller.isSuperAdmin) {
if (newRank >= callerRank) {
res.status(403).json({ error: 'You cannot assign a role at or above your own' });
return;
}
if (targetRank >= callerRank) {
res.status(403).json({ error: 'You cannot modify a user at or above your own role' });
return;
}
}
const storedRole = newRole === 'guest' ? null : newRole;
const updated = await prisma.user.upsert({
where: { pubkey },
update: { role: 'USER' },
create: { pubkey, role: 'USER' },
update: { role: storedRole },
create: { pubkey, role: storedRole },
});
res.json(user);
res.json({ ...updated, role: updated.role ?? null, isSuperAdmin: false });
} catch (err) {
console.error('Demote user error:', err);
console.error('Assign role error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
@@ -182,16 +288,17 @@ router.patch(
router.patch(
'/:pubkey',
requireAuth,
requireRole(['ADMIN']),
requires('nip05.assign'),
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
const pubkey =
const pubkeyParam =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
if (!pubkeyParam) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const hex = normalizePubkey(pubkeyParam);
const { username } = req.body;
const normalized = (username as string || '').trim().toLowerCase();
@@ -202,7 +309,10 @@ router.patch(
return;
}
const target = await prisma.user.findUnique({ where: { pubkey } });
// Tolerate legacy rows still stored as npub by matching either form.
const target = await prisma.user.findFirst({
where: { OR: [{ pubkey: hex }, { pubkey: pubkeyParam }] },
});
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
@@ -211,7 +321,7 @@ router.patch(
const existing = await prisma.user.findFirst({
where: {
username: { equals: normalized },
NOT: { pubkey },
NOT: { pubkey: target.pubkey },
},
});
@@ -221,7 +331,7 @@ router.patch(
}
const user = await prisma.user.update({
where: { pubkey },
where: { pubkey: target.pubkey },
data: { username: normalized },
});
+2
View File
@@ -0,0 +1,2 @@
/** Slug for the default organizer row (seed + migration). */
export const DEFAULT_ORGANIZER_SLUG = 'belgian-bitcoin-embassy';
+101
View File
@@ -0,0 +1,101 @@
// Central permission registry. This is the single source of truth for every
// granular permission key in the dashboard. Adding a key here automatically
// surfaces it in the GET /admin/permissions endpoint and the Roles matrix UI.
// To add a new permission: add an entry to PERMISSIONS below, then (optionally)
// grant it to roles by default in DEFAULT_ROLE_PERMISSIONS and the migration.
export type PermissionKey = string;
export interface PermissionDef {
key: PermissionKey;
label: string;
group: string;
}
// Ordered list of every permission, grouped by feature area for the matrix UI.
export const PERMISSIONS: PermissionDef[] = [
{ key: 'events.create', label: 'Create events', group: 'Events' },
{ key: 'events.edit', label: 'Edit events', group: 'Events' },
{ key: 'events.delete', label: 'Delete events', group: 'Events' },
{ key: 'organizers.manage', label: 'Manage organizers', group: 'Organizers' },
{ key: 'gallery.upload', label: 'Upload media', group: 'Gallery' },
{ key: 'gallery.delete', label: 'Delete media', group: 'Gallery' },
{ key: 'blog.draft', label: 'Draft and edit posts', group: 'Blog' },
{ key: 'blog.publish', label: 'Publish posts', group: 'Blog' },
{ key: 'blog.delete', label: 'Delete posts', group: 'Blog' },
{ key: 'faq.manage', label: 'Manage FAQ', group: 'FAQ' },
{ key: 'submissions.review', label: 'Review submissions', group: 'Submissions' },
{ key: 'board.manage', label: 'Moderate the message board', group: 'Board' },
{ key: 'moderation.act', label: 'Hide content and block pubkeys', group: 'Moderation' },
{ key: 'categories.manage', label: 'Manage categories', group: 'Categories' },
{ key: 'users.assign_role', label: 'Assign user roles', group: 'Users' },
{ key: 'nip05.assign', label: 'Assign NIP-05 usernames', group: 'Users' },
{ key: 'relays.manage', label: 'Manage relays', group: 'Relays' },
{ key: 'settings.edit', label: 'Edit site settings', group: 'Settings' },
{ key: 'roles.edit_permissions', label: 'Edit roles and permissions', group: 'Roles' },
{ key: 'api_keys.manage', label: 'Create and manage API keys', group: 'API Keys' },
{ key: 'nostr_tools.use', label: 'Use Nostr tools', group: 'Nostr Tools' },
];
// Assignable roles, highest to lowest. SuperAdmin is env-sourced and never stored
// or assignable, so it is not part of this list. Guest is the absence of a role.
export const ASSIGNABLE_ROLES = ['admin', 'moderator', 'writer'] as const;
export type AssignableRole = (typeof ASSIGNABLE_ROLES)[number];
export type EffectiveRole = 'superadmin' | AssignableRole | 'guest';
// Hierarchy ranks used for safeguard comparisons.
export const ROLE_RANK: Record<EffectiveRole, number> = {
superadmin: 4,
admin: 3,
moderator: 2,
writer: 1,
guest: 0,
};
export const ALL_PERMISSION_KEYS: ReadonlySet<PermissionKey> = new Set(
PERMISSIONS.map((p) => p.key)
);
export function isValidPermissionKey(key: string): boolean {
return ALL_PERMISSION_KEYS.has(key);
}
export function isAssignableRole(role: unknown): role is AssignableRole {
return typeof role === 'string' && (ASSIGNABLE_ROLES as readonly string[]).includes(role);
}
// Default permission sets seeded per role. Admin gets everything except the
// roles editor and API key management, which stay SuperAdmin-only by default.
// Guest gets nothing and is therefore not represented here.
export const DEFAULT_ROLE_PERMISSIONS: Record<AssignableRole, PermissionKey[]> = {
admin: PERMISSIONS.map((p) => p.key).filter(
(k) => k !== 'roles.edit_permissions' && k !== 'api_keys.manage'
),
moderator: [
'events.create',
'events.edit',
'events.delete',
'submissions.review',
'moderation.act',
'gallery.upload',
'board.manage',
'categories.manage',
'faq.manage',
],
writer: ['blog.draft', 'gallery.upload', 'events.create'],
};
+30
View File
@@ -10,6 +10,7 @@ import morgan from 'morgan';
import authRouter from './api/auth';
import postsRouter from './api/posts';
import meetupsRouter from './api/meetups';
import organizersRouter from './api/organizers';
import moderationRouter from './api/moderation';
import usersRouter from './api/users';
import categoriesRouter from './api/categories';
@@ -21,11 +22,34 @@ import mediaRouter from './api/media';
import faqsRouter from './api/faqs';
import calendarRouter from './api/calendar';
import nip05Router from './api/nip05';
import messagesRouter from './api/messages';
import adminMessagesRouter from './api/adminMessages';
import userRelaysRouter from './api/userRelays';
import rolesRouter from './api/roles';
import apiKeysRouter from './api/apiKeys';
const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
// Refuse to boot in production with a missing/weak JWT secret — otherwise anyone
// could forge admin JWTs against the shipped `change-me-in-production` default.
if (process.env.NODE_ENV === 'production') {
const secret = process.env.JWT_SECRET;
if (!secret || secret === 'change-me-in-production' || secret.length < 32) {
console.error(
'FATAL: JWT_SECRET must be set to a strong value (>= 32 chars) in production. Refusing to start.'
);
process.exit(1);
}
}
// A rejected promise that escapes a handler should be logged, not silently crash
// (or, on older Express, hang) the process.
process.on('unhandledRejection', (reason) => {
console.error('Unhandled promise rejection:', reason);
});
// Trust the first proxy (nginx) so req.ip returns the real client IP
app.set('trust proxy', 1);
@@ -37,6 +61,7 @@ app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/posts', postsRouter);
app.use('/api/meetups', meetupsRouter);
app.use('/api/organizers', organizersRouter);
app.use('/api/moderation', moderationRouter);
app.use('/api/users', usersRouter);
app.use('/api/categories', categoriesRouter);
@@ -48,6 +73,11 @@ app.use('/api/media', mediaRouter);
app.use('/api/faqs', faqsRouter);
app.use('/api/calendar', calendarRouter);
app.use('/api/nip05', nip05Router);
app.use('/api/messages', messagesRouter);
app.use('/api/admin/messages', adminMessagesRouter);
app.use('/api/admin', rolesRouter);
app.use('/api/api-keys', apiKeysRouter);
app.use('/api/user-relays', userRelaysRouter);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
+36
View File
@@ -0,0 +1,36 @@
import type { Response } from 'express';
/**
* When the DB was never migrated for organizers (or db push failed), Prisma throws.
* Return a clear JSON error so operators know to run `prisma migrate deploy`, not `db push`.
*/
export function respondIfOrganizerMigrationNeeded(err: unknown, res: Response): boolean {
const e = err as {
code?: string;
meta?: { modelName?: string; table?: string; column?: string };
message?: string;
};
const msg = String(e?.message ?? '');
if (e?.code === 'P2021') {
const model = e.meta?.modelName ?? '';
const table = e.meta?.table ?? '';
if (model === 'Organizer' || table.includes('Organizer')) {
res.status(503).json({
error:
'Database is missing the Organizer table. On the server run: cd backend && npm run migrate:deploy (use Prisma migrate, not db push).',
});
return true;
}
}
if (e?.code === 'P2022' && (msg.includes('organizerId') || e.meta?.column === 'organizerId')) {
res.status(503).json({
error:
'Database is missing meetup organizer columns. On the server run: cd backend && npm run migrate:deploy (do not use prisma db push for this upgrade).',
});
return true;
}
return false;
}
+47 -6
View File
@@ -1,5 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { authService, type ResolvedAccess } from '../services/auth';
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
@@ -12,11 +14,13 @@ declare global {
namespace Express {
interface Request {
user?: AuthPayload;
access?: ResolvedAccess;
isApiKey?: boolean;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
export async function requireAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
@@ -24,6 +28,28 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
const token = header.slice(7);
// API keys authenticate programmatic clients. They carry their own scoped
// permission set rather than a role, so we pre-resolve access here. Wrapped in
// try/catch because Express 4 does not catch rejections from async middleware.
if (looksLikeApiKey(token)) {
try {
const access = await resolveApiKey(token);
if (!access) {
res.status(401).json({ error: 'Invalid or revoked API key' });
return;
}
req.user = { pubkey: access.pubkey, role: 'apikey' };
req.access = access;
req.isApiKey = true;
next();
} catch (err) {
console.error('API key resolution error:', err);
res.status(500).json({ error: 'Internal server error' });
}
return;
}
try {
const payload = jwt.verify(token, JWT_SECRET) as AuthPayload;
req.user = payload;
@@ -33,16 +59,31 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
}
export function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
// Resolves the requester's effective access (env SuperAdmin list plus database)
// and gates the request on a single permission key. SuperAdmin bypasses every
// check. The resolved access is attached to req.access for downstream handlers.
export function requires(permission: string) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (!roles.includes(req.user.role)) {
try {
// API key access is already resolved (and scoped) in requireAuth; for JWT
// callers, resolve their live role-based access here.
let access = req.access;
if (!req.isApiKey || !access) {
access = await authService.resolveAccess(req.user.pubkey);
req.access = access;
}
if (access.isSuperAdmin || access.permissions.has(permission)) {
next();
return;
}
res.status(403).json({ error: 'Insufficient permissions' });
return;
} catch (err) {
console.error('Permission check error:', err);
res.status(500).json({ error: 'Internal server error' });
}
next();
};
}
+57
View File
@@ -0,0 +1,57 @@
import crypto from 'crypto';
import { prisma } from '../db/prisma';
import type { ResolvedAccess } from './auth';
// Raw keys are prefixed so the auth middleware can distinguish them from JWTs
// (which always start with "eyJ").
export const API_KEY_PREFIX = 'bbe_';
const DISPLAY_PREFIX_LENGTH = API_KEY_PREFIX.length + 8;
export function hashApiKey(rawKey: string): string {
return crypto.createHash('sha256').update(rawKey).digest('hex');
}
// Generates a new random key. Returns the raw key (shown once), its display
// prefix, and the hash to persist.
export function generateApiKey(): { rawKey: string; prefix: string; keyHash: string } {
const rawKey = API_KEY_PREFIX + crypto.randomBytes(32).toString('hex');
return {
rawKey,
prefix: rawKey.slice(0, DISPLAY_PREFIX_LENGTH),
keyHash: hashApiKey(rawKey),
};
}
export function looksLikeApiKey(token: string): boolean {
return token.startsWith(API_KEY_PREFIX);
}
// Resolves a raw API key into an access object scoped to the key's permissions.
// Returns null when the key is unknown or revoked. Identity is attributed to the
// pubkey that created the key, but the permissions come from the key, not the
// creator's role.
export async function resolveApiKey(rawKey: string): Promise<ResolvedAccess | null> {
const keyHash = hashApiKey(rawKey);
const key = await prisma.apiKey.findUnique({ where: { keyHash } });
if (!key || key.revokedAt) return null;
let permissions: string[] = [];
try {
const parsed = JSON.parse(key.permissions);
if (Array.isArray(parsed)) permissions = parsed.filter((p): p is string => typeof p === 'string');
} catch {
permissions = [];
}
// Best-effort usage tracking; never block the request on it.
prisma.apiKey
.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } })
.catch(() => undefined);
return {
pubkey: key.createdByPubkey,
role: 'guest',
isSuperAdmin: false,
permissions: new Set(permissions),
};
}
+63 -20
View File
@@ -2,10 +2,48 @@ import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
import {
ALL_PERMISSION_KEYS,
isAssignableRole,
type EffectiveRole,
} from '../constants/permissions';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export interface ResolvedAccess {
pubkey: string;
role: EffectiveRole;
isSuperAdmin: boolean;
permissions: Set<string>;
}
// Reads the SuperAdmin pubkey list from the environment, decoding npub to hex.
// Falls back to the legacy ADMIN_PUBKEYS variable so existing deployments keep
// working. The env-admin concept is now SuperAdmin.
function getSuperadminPubkeys(): string[] {
const raw = process.env.SUPERADMIN_PUBKEYS ?? process.env.ADMIN_PUBKEYS ?? '';
return raw
.split(',')
.map((p) => p.trim())
.filter(Boolean)
.map((p) => {
if (p.startsWith('npub1')) {
try {
const { data } = nip19.decode(p);
return data as string;
} catch {
return p;
}
}
return p;
});
}
export function isSuperadmin(pubkey: string): boolean {
return getSuperadminPubkeys().includes(pubkey);
}
interface StoredChallenge {
challenge: string;
expiresAt: number;
@@ -62,29 +100,34 @@ export const authService = {
return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' });
},
async getRole(pubkey: string): Promise<string> {
const adminPubkeys = (process.env.ADMIN_PUBKEYS || '')
.split(',')
.map((p) => p.trim())
.filter(Boolean)
.map((p) => {
if (p.startsWith('npub1')) {
try {
const { data } = nip19.decode(p);
return data as string;
} catch {
return p;
}
}
return p;
});
isSuperadmin,
if (adminPubkeys.includes(pubkey)) return 'ADMIN';
// Resolves the effective role and permission set for a pubkey, live, from the
// env SuperAdmin list plus the database. This is the authoritative source for
// authorization. The role baked into a JWT is only used for display.
async resolveAccess(pubkey: string): Promise<ResolvedAccess> {
if (isSuperadmin(pubkey)) {
return {
pubkey,
role: 'superadmin',
isSuperAdmin: true,
permissions: new Set(ALL_PERMISSION_KEYS),
};
}
const user = await prisma.user.findUnique({ where: { pubkey } });
if (user?.role === 'MODERATOR') return 'MODERATOR';
if (user?.role === 'ADMIN') return 'ADMIN';
const role: EffectiveRole = isAssignableRole(user?.role) ? user!.role : 'guest';
return 'USER';
if (role === 'guest') {
return { pubkey, role, isSuperAdmin: false, permissions: new Set() };
}
const rows = await prisma.rolePermission.findMany({ where: { role } });
return {
pubkey,
role,
isSuperAdmin: false,
permissions: new Set(rows.map((r) => r.permission)),
};
},
};
+113
View File
@@ -0,0 +1,113 @@
const LNBITS_URL = (process.env.LNBITS_URL || 'https://legend.lnbits.com').replace(/\/$/, '');
const LNBITS_API_KEY = process.env.LNBITS_API_KEY || '';
export interface CreateInvoiceResult {
payment_hash: string;
payment_request: string;
checking_id: string;
}
function pickBolt11(data: Record<string, unknown>): string {
const pr = data.payment_request ?? data.bolt11;
if (typeof pr === 'string' && pr.length > 0) return pr;
return '';
}
export async function createIncomingInvoice(params: {
amountSats: number;
memo: string;
webhookUrl: string;
expirySeconds?: number;
}): Promise<CreateInvoiceResult> {
if (!LNBITS_API_KEY) {
throw new Error('LNBITS_API_KEY is not configured');
}
const body: Record<string, unknown> = {
out: false,
amount: params.amountSats,
unit: 'sat',
memo: params.memo,
expiry: params.expirySeconds ?? 3600,
webhook: params.webhookUrl,
};
const res = await fetch(`${LNBITS_URL}/api/v1/payments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': LNBITS_API_KEY,
},
body: JSON.stringify(body),
});
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
const detail =
typeof data.detail === 'string'
? data.detail
: Array.isArray(data.detail)
? JSON.stringify(data.detail)
: typeof data.message === 'string'
? data.message
: res.statusText;
throw new Error(`LNbits invoice failed: ${detail || res.status}`);
}
const payment_hash = typeof data.payment_hash === 'string' ? data.payment_hash : '';
const payment_request = pickBolt11(data);
const checking_id =
typeof data.checking_id === 'string' && data.checking_id.length > 0
? data.checking_id
: payment_hash;
if (!payment_hash || !payment_request) {
throw new Error('LNbits returned an unexpected invoice payload');
}
return { payment_hash, payment_request, checking_id };
}
export interface PaymentVerifyResult {
paid: boolean;
amountMsat?: number;
}
/** Verify invoice belongs to our wallet and is paid (requires API key). */
export async function verifyIncomingPaymentPaid(paymentHash: string): Promise<PaymentVerifyResult> {
if (!LNBITS_API_KEY) {
throw new Error('LNBITS_API_KEY is not configured');
}
const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`, {
headers: { 'X-Api-Key': LNBITS_API_KEY },
});
if (res.status === 404) {
return { paid: false };
}
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
return { paid: false };
}
const paid = data.paid === true;
let amountMsat: number | undefined;
const details = data.details as Record<string, unknown> | undefined;
if (details && typeof details.amount === 'number') {
amountMsat = details.amount;
}
return { paid, amountMsat };
}
/** Public LNbits poll (no key): { paid: boolean } */
export async function getPublicPaymentStatus(paymentHash: string): Promise<boolean> {
const res = await fetch(`${LNBITS_URL}/api/v1/payments/${paymentHash}`);
if (!res.ok) return false;
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
return data.paid === true;
}
+162 -54
View File
@@ -1,6 +1,14 @@
import WebSocket from 'ws';
import { SimplePool, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
// nostr-tools' SimplePool relies on a global WebSocket. Node only exposes one
// from v22 onward, so on older runtimes (this backend runs Node 20) every relay
// query silently fails without this polyfill. Set before any pool connects.
if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === 'undefined') {
(globalThis as { WebSocket?: unknown }).WebSocket = WebSocket;
}
const pool = new SimplePool();
async function getRelayUrls(): Promise<string[]> {
@@ -11,7 +19,62 @@ async function getRelayUrls(): Promise<string[]> {
return relays.map((r) => r.url);
}
function dedupeRelays(...lists: string[][]): string[] {
return [...new Set(lists.flat().filter(Boolean))];
}
async function cacheEvent(event: any) {
await prisma.nostrEventCache.upsert({
where: { eventId: event.id },
update: {
content: event.content,
tags: JSON.stringify(event.tags),
},
create: {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
tags: JSON.stringify(event.tags),
createdAt: event.created_at,
},
});
}
export const nostrService = {
async fetchNip65Relays(pubkey: string): Promise<{ write: string[]; read: string[] }> {
const siteRelays = await getRelayUrls();
if (siteRelays.length === 0) return { write: [], read: [] };
try {
const event = await pool.get(siteRelays, {
kinds: [10002],
authors: [pubkey],
});
if (!event) return { write: [], read: [] };
const write: string[] = [];
const read: string[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'r' || !tag[1]) continue;
const url = tag[1];
const marker = tag[2];
if (marker === 'write') {
write.push(url);
} else if (marker === 'read') {
read.push(url);
} else {
write.push(url);
read.push(url);
}
}
return { write, read };
} catch (err) {
console.error('Failed to fetch NIP-65 relays for', pubkey, err);
return { write: [], read: [] };
}
},
async fetchEvent(eventId: string, skipCache = false) {
if (!skipCache) {
const cached = await prisma.nostrEventCache.findUnique({
@@ -29,34 +92,20 @@ export const nostrService = {
}
}
const relays = await getRelayUrls();
if (relays.length === 0) return null;
const siteRelays = await getRelayUrls();
if (siteRelays.length === 0) return null;
try {
const event = await pool.get(relays, { ids: [eventId] });
if (!event) return null;
await prisma.nostrEventCache.upsert({
where: { eventId: event.id },
update: {
content: event.content,
tags: JSON.stringify(event.tags),
},
create: {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
tags: JSON.stringify(event.tags),
createdAt: event.created_at,
},
});
return event;
const event = await pool.get(siteRelays, { ids: [eventId] });
if (event) {
await cacheEvent(event);
return event;
}
} catch (err) {
console.error('Failed to fetch event:', err);
return null;
console.error('Failed to fetch event from site relays:', err);
}
return null;
},
async fetchLongformEvent(naddrStr: string) {
@@ -69,40 +118,99 @@ export const nostrService = {
return null;
}
const relays = decoded.relays?.length
? decoded.relays
: await getRelayUrls();
if (relays.length === 0) return null;
// Cache-first: addressable events are keyed by kind+pubkey+d-tag, not by a
// stable event id, so look them up among cached events of the same author.
// Avoids re-querying relays on every repeat visit to the same naddr.
const cached = await prisma.nostrEventCache.findMany({
where: { kind: decoded.kind, pubkey: decoded.pubkey },
});
for (const c of cached) {
let tags: string[][] = [];
try {
tags = JSON.parse(c.tags);
} catch {
continue;
}
const dTag = tags.find((t) => t[0] === 'd');
if (dTag?.[1] === decoded.identifier) {
return {
id: c.eventId,
kind: c.kind,
pubkey: c.pubkey,
content: c.content,
tags,
created_at: c.createdAt,
};
}
}
const siteRelays = await getRelayUrls();
const naddrRelays = decoded.relays || [];
const filter = {
kinds: [decoded.kind],
authors: [decoded.pubkey],
'#d': [decoded.identifier],
};
// Try naddr-embedded relays first, then site relays
const firstTry = dedupeRelays(naddrRelays, siteRelays);
if (firstTry.length > 0) {
try {
const event = await pool.get(firstTry, filter);
if (event) {
await cacheEvent(event);
return event;
}
} catch (err) {
console.error('Failed to fetch longform event (first pass):', err);
}
}
// Fallback: try author's NIP-65 write relays
const nip65 = await nostrService.fetchNip65Relays(decoded.pubkey);
if (nip65.write.length > 0) {
const nip65Relays = dedupeRelays(nip65.write);
try {
const event = await pool.get(nip65Relays, filter);
if (event) {
await cacheEvent(event);
return event;
}
} catch (err) {
console.error('Failed to fetch longform event (NIP-65 fallback):', err);
}
}
return null;
},
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
// event id to the underlying Nostr event, reusing the cache-aware fetchers.
// Returns null if the string can't be decoded or the event isn't found.
async resolveEventByIdentifier(identifier: string) {
const trimmed = identifier.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return nostrService.fetchEvent(trimmed.toLowerCase());
}
let decoded;
try {
const event = await pool.get(relays, {
kinds: [decoded.kind],
authors: [decoded.pubkey],
'#d': [decoded.identifier],
});
if (!event) return null;
await prisma.nostrEventCache.upsert({
where: { eventId: event.id },
update: {
content: event.content,
tags: JSON.stringify(event.tags),
},
create: {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
content: event.content,
tags: JSON.stringify(event.tags),
createdAt: event.created_at,
},
});
return event;
} catch (err) {
console.error('Failed to fetch longform event:', err);
decoded = nip19.decode(trimmed);
} catch {
return null;
}
switch (decoded.type) {
case 'naddr':
return nostrService.fetchLongformEvent(trimmed);
case 'nevent':
return nostrService.fetchEvent((decoded.data as nip19.EventPointer).id);
case 'note':
return nostrService.fetchEvent(decoded.data as string);
default:
return null;
}
},
async fetchReactions(eventId: string) {
+124
View File
@@ -0,0 +1,124 @@
import { prisma } from '../db/prisma';
import { nostrService } from './nostr';
export interface ImportPostInput {
nostrEventId: string;
naddr?: string | null;
title: string;
excerpt?: string | null;
authorPubkey: string;
publishedAt?: number | string | null;
tags?: string[];
visible?: boolean;
}
// Upserts a blog Post from a Nostr longform event. Shared by the manual import
// endpoint and the submission-approval flow so both produce identical results
// (auto slug, category links from `t` tags, visible by default).
export async function importPostFromNostr(input: ImportPostInput) {
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags, visible } = input;
const slugBase = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
const post = await prisma.post.upsert({
where: { nostrEventId },
update: {
title,
excerpt: excerpt || undefined,
naddr: naddr || undefined,
},
create: {
nostrEventId,
naddr: naddr || null,
title,
slug,
excerpt: excerpt || null,
authorPubkey,
visible: visible ?? true,
publishedAt: publishedAt
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
: new Date(),
},
});
if (Array.isArray(tags) && tags.length > 0) {
const categoryIds: string[] = [];
for (const tag of tags) {
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
if (!catSlug) continue;
const category = await prisma.category.upsert({
where: { slug: catSlug },
update: {},
create: {
name: tag.charAt(0).toUpperCase() + tag.slice(1),
slug: catSlug,
},
});
categoryIds.push(category.id);
}
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
if (categoryIds.length > 0) {
await prisma.postCategory.createMany({
data: categoryIds.map((categoryId) => ({
postId: post.id,
categoryId,
})),
});
}
}
return prisma.post.findUnique({
where: { id: post.id },
include: { categories: { include: { category: true } } },
});
}
export interface SubmissionRef {
eventId: string | null;
naddr: string | null;
title: string;
authorPubkey: string;
}
// Resolves the Nostr longform event referenced by a submission into a blog
// import payload. Returns null when the event cannot be resolved into something
// publishable (e.g. naddr that no relay can serve and no fallback event id).
export async function resolveSubmissionImport(
submission: SubmissionRef
): Promise<ImportPostInput | null> {
const event = submission.naddr
? await nostrService.fetchLongformEvent(submission.naddr)
: submission.eventId
? await nostrService.fetchEvent(submission.eventId)
: null;
const nostrEventId: string | undefined = event?.id || submission.eventId || undefined;
if (!nostrEventId) return null;
const eventTags: string[][] = Array.isArray(event?.tags) ? (event!.tags as string[][]) : [];
const titleTag = eventTags.find((t) => t[0] === 'title')?.[1];
const topicTags = eventTags
.filter((t) => t[0] === 't' && t[1])
.map((t) => (t[1] as string).toLowerCase());
const excerpt = ((event?.content as string) || '')
.slice(0, 200)
.replace(/[#*_\n]/g, '')
.trim();
return {
nostrEventId,
naddr: submission.naddr || undefined,
title: submission.title || titleTag || 'Untitled',
excerpt: excerpt || undefined,
authorPubkey: event?.pubkey || submission.authorPubkey,
publishedAt: event?.created_at ?? null,
tags: topicTags.length > 0 ? topicTags : undefined,
visible: true,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { nip19 } from 'nostr-tools';
// Relays and the rest of the app key identities by lowercase hex pubkeys.
// Pubkeys may arrive as npub/nprofile, so normalize them to hex. Returns null
// when the input cannot be interpreted as a pubkey.
export function toHexPubkey(pubkey: string | null | undefined): string | null {
if (!pubkey) return null;
const trimmed = pubkey.trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === 'npub') return decoded.data as string;
if (decoded.type === 'nprofile') return (decoded.data as { pubkey: string }).pubkey;
} catch {
// fall through
}
return null;
}
// Normalizes to hex when possible, otherwise returns the trimmed original so we
// never silently drop an identity we cannot decode.
export function normalizePubkey(pubkey: string): string {
return toHexPubkey(pubkey) ?? pubkey.trim();
}
+34 -9
View File
@@ -6,13 +6,38 @@ export async function GET(req: NextRequest) {
const upstream = new URL(apiUrl('/nip05'));
if (name) upstream.searchParams.set('name', name);
const res = await fetch(upstream.toString(), { cache: 'no-store' });
const data = await res.json();
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
});
try {
const res = await fetch(upstream.toString(), { cache: 'no-store' });
if (!res.ok) {
return NextResponse.json(
{},
{
status: 502,
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
}
);
}
const data = await res.json();
return NextResponse.json(data, {
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
});
} catch (err) {
console.error('NIP-05 proxy error:', err);
return NextResponse.json(
{},
{
status: 502,
headers: {
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
},
}
);
}
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatDate } from "@/lib/utils";
import { Copy, Check, KeyRound, Plus, Trash2, ShieldAlert } from "lucide-react";
interface PermissionDef {
key: string;
label: string;
group: string;
}
interface ApiKey {
id: string;
name: string;
prefix: string;
permissions: string[];
createdByPubkey: string;
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
}
export default function ApiKeysPage() {
const { can } = useAuth();
const allowed = can("api_keys.manage");
const [permissions, setPermissions] = useState<PermissionDef[]>([]);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [name, setName] = useState("");
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [creating, setCreating] = useState(false);
const [revealedKey, setRevealedKey] = useState<{ name: string; key: string } | null>(null);
const [copied, setCopied] = useState(false);
const load = async () => {
setLoading(true);
try {
const [registry, list] = await Promise.all([
api.getPermissionRegistry(),
api.getApiKeys(),
]);
setPermissions(registry.permissions);
setKeys(list);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (allowed) load();
else setLoading(false);
}, [allowed]);
const groups = useMemo(() => {
const order: string[] = [];
const byGroup: Record<string, PermissionDef[]> = {};
for (const perm of permissions) {
if (!byGroup[perm.group]) {
byGroup[perm.group] = [];
order.push(perm.group);
}
byGroup[perm.group].push(perm);
}
return order.map((group) => ({ group, perms: byGroup[group] }));
}, [permissions]);
const selectedKeys = useMemo(
() => Object.entries(selected).filter(([, v]) => v).map(([k]) => k),
[selected]
);
const toggle = (key: string) => {
setSelected((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleCreate = async () => {
if (!name.trim() || selectedKeys.length === 0) return;
setCreating(true);
setError("");
try {
const created = await api.createApiKey({ name: name.trim(), permissions: selectedKeys });
setRevealedKey({ name: created.name, key: created.key });
setCopied(false);
setName("");
setSelected({});
await load();
} catch (err: any) {
setError(err.message);
} finally {
setCreating(false);
}
};
const handleRevoke = async (id: string) => {
setError("");
try {
await api.revokeApiKey(id);
await load();
} catch (err: any) {
setError(err.message);
}
};
const handleCopy = async () => {
if (!revealedKey) return;
try {
await navigator.clipboard.writeText(revealedKey.key);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// ignore
}
};
if (!allowed) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">
You do not have permission to manage API keys.
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading API keys...</div>
</div>
);
}
const labelFor = (key: string) => permissions.find((p) => p.key === key)?.label ?? key;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-on-surface">API Keys</h1>
<p className="text-on-surface/60 text-sm mt-1">
Create scoped API keys for programmatic access. A key is shown in full
only once, right after you create it, so copy it immediately.
</p>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{revealedKey && (
<div className="bg-primary-container/10 border border-primary/30 rounded-xl p-6 space-y-3">
<div className="flex items-center gap-2 text-primary font-semibold">
<ShieldAlert size={18} />
Copy your new key now it won&apos;t be shown again
</div>
<p className="text-on-surface/70 text-sm">
Key for <span className="font-semibold">{revealedKey.name}</span>:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm break-all">
{revealedKey.key}
</code>
<button
onClick={handleCopy}
className="flex items-center gap-2 px-4 py-3 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity whitespace-nowrap"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<button
onClick={() => setRevealedKey(null)}
className="text-on-surface/50 text-sm hover:text-on-surface transition-colors"
>
I&apos;ve saved it, dismiss
</button>
</div>
)}
{/* Create form */}
<div className="bg-surface-container-low rounded-xl p-6 space-y-5">
<h2 className="text-sm font-semibold text-on-surface/70">Create API Key</h2>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Name
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Blog publishing bot"
maxLength={100}
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-3">
Permissions
</p>
<div className="space-y-4">
{groups.map(({ group, perms }) => (
<div key={group}>
<p className="text-xs font-bold uppercase tracking-wide text-on-surface/40 mb-2">
{group}
</p>
<div className="grid sm:grid-cols-2 gap-2">
{perms.map((perm) => (
<label
key={perm.key}
className="flex items-start gap-2 cursor-pointer text-sm text-on-surface"
>
<input
type="checkbox"
checked={!!selected[perm.key]}
onChange={() => toggle(perm.key)}
className="mt-0.5 h-4 w-4 accent-primary cursor-pointer"
/>
<span>
{perm.label}
<span className="block text-on-surface/40 text-xs font-mono">
{perm.key}
</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<button
onClick={handleCreate}
disabled={!name.trim() || selectedKeys.length === 0 || creating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Plus size={16} />
{creating ? "Creating..." : "Create API Key"}
</button>
</div>
{/* Existing keys */}
<div className="space-y-3">
<h2 className="text-sm font-semibold text-on-surface/70">Existing Keys</h2>
{keys.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<KeyRound size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">No API keys yet.</p>
</div>
) : (
keys.map((key) => (
<div
key={key.id}
className={`bg-surface-container-low rounded-xl p-6 ${key.revokedAt ? "opacity-60" : ""}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-on-surface">{key.name}</h3>
{key.revokedAt && (
<span className="text-xs font-bold text-error bg-error/10 px-2 py-0.5 rounded-full">
Revoked
</span>
)}
</div>
<p className="font-mono text-sm text-on-surface/60 mt-1">{key.prefix}</p>
<p className="text-on-surface/40 text-xs mt-1">
Created {formatDate(key.createdAt)}
{key.lastUsedAt ? ` · Last used ${formatDate(key.lastUsedAt)}` : " · Never used"}
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
{key.permissions.map((p) => (
<span
key={p}
className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full"
title={p}
>
{labelFor(p)}
</span>
))}
</div>
</div>
{!key.revokedAt && (
<button
onClick={() => handleRevoke(key.id)}
className="text-on-surface-variant/50 hover:text-error transition-colors p-2 rounded-lg hover:bg-error/10 shrink-0"
title="Revoke key"
>
<Trash2 size={16} />
</button>
)}
</div>
</div>
))
)}
</div>
</div>
);
}
+56 -15
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
import {
Pencil,
Trash2,
@@ -56,13 +57,24 @@ export default function BlogPage() {
setFetching(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
const data = await api.fetchNostrEvent(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
setImportPreview(data);
const isNaddr = importInput.trim().startsWith("naddr");
const event = isNaddr
? await fetchLongformFromRelays(importInput.trim())
: await fetchEventFromRelays(importInput.trim());
if (!event) {
setError("Event not found on relays");
setImportPreview(null);
return;
}
const titleTag = event.tags?.find((t: string[]) => t[0] === "title");
setImportPreview({
...event,
title: titleTag?.[1] || "Untitled",
});
} catch (err: any) {
setError(err.message);
setError(err.message || "Failed to fetch event");
setImportPreview(null);
} finally {
setFetching(false);
@@ -70,14 +82,29 @@ export default function BlogPage() {
};
const handleImport = async () => {
if (!importInput.trim()) return;
if (!importPreview) return;
setImporting(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
await api.importPost(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
const isNaddr = importInput.trim().startsWith("naddr");
const excerpt = (importPreview.content || "")
.slice(0, 200)
.replace(/[#*_\n]/g, "")
.trim();
const tags: string[] = (importPreview.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => t[1].toLowerCase());
await api.importPost({
nostrEventId: importPreview.id,
naddr: isNaddr ? importInput.trim() : undefined,
title: importPreview.title || "Untitled",
excerpt: excerpt || undefined,
authorPubkey: importPreview.pubkey,
publishedAt: importPreview.created_at,
tags: tags.length > 0 ? tags : undefined,
});
setImportInput("");
setImportPreview(null);
setImportOpen(false);
@@ -95,7 +122,7 @@ export default function BlogPage() {
title: post.title || "",
slug: post.slug || "",
excerpt: post.excerpt || "",
categories: post.categories?.map((c: any) => c.id || c) || [],
categories: post.categories?.map((c: any) => c.categoryId || c.category?.id || c) || [],
featured: post.featured || false,
visible: post.visible !== false,
});
@@ -184,6 +211,20 @@ export default function BlogPage() {
<p className="text-on-surface/60 text-sm mt-1 line-clamp-3">
{importPreview.content?.slice(0, 300)}...
</p>
{(() => {
const previewTags = (importPreview.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => t[1]);
return previewTags.length > 0 ? (
<div className="flex flex-wrap gap-1.5 mt-2">
{previewTags.map((tag: string) => (
<span key={tag} className="rounded-full px-2 py-0.5 text-xs bg-primary/10 text-primary font-medium">
{tag}
</span>
))}
</div>
) : null;
})()}
<button
onClick={handleImport}
disabled={importing}
@@ -303,12 +344,12 @@ export default function BlogPage() {
<p className="text-on-surface/50 text-sm truncate">/{post.slug}</p>
{post.categories?.length > 0 && (
<div className="flex gap-2 mt-2">
{post.categories.map((cat: any) => (
{post.categories.map((pc: any) => (
<span
key={cat.id || cat}
key={pc.categoryId || pc.category?.id}
className="rounded-full px-2 py-0.5 text-xs bg-surface-container-highest text-on-surface/60"
>
{cat.name || cat}
{pc.category?.name || pc.categoryId}
</span>
))}
</div>
+120 -6
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { slugify } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
Plus,
@@ -23,7 +23,7 @@ import {
Check,
} from "lucide-react";
import { MediaPickerModal } from "@/components/admin/MediaPickerModal";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
interface Meetup {
id: string;
@@ -37,6 +37,8 @@ interface Meetup {
status: string;
featured: boolean;
visibility: string;
organizerId?: string;
organizer?: { id: string; name: string; slug: string };
createdAt: string;
updatedAt: string;
}
@@ -52,6 +54,7 @@ interface MeetupForm {
status: string;
featured: boolean;
visibility: string;
organizerId: string;
}
const emptyForm: MeetupForm = {
@@ -65,8 +68,17 @@ const emptyForm: MeetupForm = {
status: "DRAFT",
featured: false,
visibility: "PUBLIC",
organizerId: "",
};
function defaultOrganizerId(organizers: { id: string; slug: string }[]): string {
return (
organizers.find((o) => o.slug === "belgian-bitcoin-embassy")?.id ||
organizers[0]?.id ||
""
);
}
// Statuses that can be manually set by an admin
const EDITABLE_STATUS_OPTIONS = ["DRAFT", "PUBLISHED", "CANCELLED"] as const;
type EditableStatus = (typeof EDITABLE_STATUS_OPTIONS)[number];
@@ -229,10 +241,20 @@ export default function EventsPage() {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
const [organizers, setOrganizers] = useState<{ id: string; name: string; slug: string }[]>([]);
const [showAddOrganizer, setShowAddOrganizer] = useState(false);
const [newOrgName, setNewOrgName] = useState("");
const [newOrgSlug, setNewOrgSlug] = useState("");
const [savingOrganizer, setSavingOrganizer] = useState(false);
const loadMeetups = async () => {
try {
const data = await api.getMeetups({ admin: true });
const [data, orgs] = await Promise.all([
api.getMeetups({ admin: true }),
api.getOrganizers(),
]);
setMeetups(data as Meetup[]);
setOrganizers(orgs);
} catch (err: any) {
setError(err.message);
} finally {
@@ -245,13 +267,38 @@ export default function EventsPage() {
}, []);
const openCreate = () => {
setForm(emptyForm);
setForm({ ...emptyForm, organizerId: defaultOrganizerId(organizers) });
setShowAddOrganizer(false);
setNewOrgName("");
setNewOrgSlug("");
setEditingId(null);
setShowForm(true);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
};
const handleCreateOrganizer = async () => {
const slug = newOrgSlug.trim() || slugify(newOrgName);
if (!newOrgName.trim() || !slug) return;
setSavingOrganizer(true);
setError("");
try {
const o = await api.createOrganizer({ name: newOrgName.trim(), slug });
setOrganizers((prev) => [...prev, o].sort((a, b) => a.name.localeCompare(b.name)));
setForm((f) => ({ ...f, organizerId: o.id }));
setShowAddOrganizer(false);
setNewOrgName("");
setNewOrgSlug("");
} catch (err: any) {
setError(err.message);
} finally {
setSavingOrganizer(false);
}
};
const openEdit = (meetup: Meetup) => {
setShowAddOrganizer(false);
setNewOrgName("");
setNewOrgSlug("");
setForm({
title: meetup.title,
description: meetup.description || "",
@@ -263,6 +310,7 @@ export default function EventsPage() {
status: meetup.status || "DRAFT",
featured: meetup.featured || false,
visibility: meetup.visibility || "PUBLIC",
organizerId: meetup.organizerId || meetup.organizer?.id || defaultOrganizerId(organizers),
});
setEditingId(meetup.id);
setShowForm(true);
@@ -284,6 +332,7 @@ export default function EventsPage() {
status: form.status,
featured: form.featured,
visibility: form.visibility,
organizerId: form.organizerId || undefined,
};
if (editingId) {
@@ -502,6 +551,61 @@ export default function EventsPage() {
<option value="HIDDEN">Hidden</option>
</select>
</div>
<div className="md:col-span-2 space-y-3">
<label className="text-on-surface/60 text-xs block">Organizer</label>
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<select
value={form.organizerId}
onChange={(e) => setForm({ ...form, organizerId: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full sm:flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40"
>
{organizers.length === 0 ? (
<option value="">No organizers add one in Organizers</option>
) : (
organizers.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))
)}
</select>
<button
type="button"
onClick={() => setShowAddOrganizer((v) => !v)}
className="px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface/80 hover:text-on-surface text-sm font-medium shrink-0"
>
{showAddOrganizer ? "Cancel add" : "Add new organizer"}
</button>
</div>
{showAddOrganizer && (
<div className="rounded-lg border border-surface-container-highest p-4 space-y-3">
<input
placeholder="Organizer name"
value={newOrgName}
onChange={(e) => {
const name = e.target.value;
setNewOrgName(name);
setNewOrgSlug(slugify(name));
}}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<input
placeholder="URL slug"
value={newOrgSlug}
onChange={(e) => setNewOrgSlug(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button
type="button"
onClick={handleCreateOrganizer}
disabled={savingOrganizer || !newOrgName.trim()}
className="px-4 py-2 rounded-lg bg-primary/20 text-primary font-semibold text-sm hover:bg-primary/30 disabled:opacity-50"
>
{savingOrganizer ? "Creating…" : "Create and select"}
</button>
</div>
)}
</div>
<div className="md:col-span-2">
<label className="text-on-surface/60 text-xs mb-2 block">
External registration link{" "}
@@ -579,7 +683,12 @@ export default function EventsPage() {
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.title || !form.date}
disabled={
saving ||
!form.title ||
!form.date ||
(!editingId && !form.organizerId)
}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
@@ -761,13 +870,18 @@ export default function EventsPage() {
<EyeOff size={12} className="text-on-surface/40 shrink-0" />
)}
</div>
{meetup.organizer?.name && (
<p className="text-on-surface/40 text-xs mb-1 truncate">
{meetup.organizer.name}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<StatusDropdown
meetup={meetup}
onChange={(v) => handlePatch(meetup.id, { status: v })}
/>
<span className="text-on-surface/50 text-xs">
{meetup.date ? formatDate(meetup.date) : "No date"}
{meetup.date ? formatMeetupCivilDateLong(meetup.date) : "No date"}
{meetup.location && ` · ${meetup.location}`}
</span>
</div>
+8 -4
View File
@@ -6,21 +6,25 @@ import { useAuth } from "@/hooks/useAuth";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const { user, loading, isSuperAdmin, permissions } = useAuth();
const router = useRouter();
// Access to the dashboard requires at least one elevated permission, or being
// a SuperAdmin. Guests (no permissions) are sent to their own dashboard.
const hasDashboardAccess = isSuperAdmin || permissions.length > 0;
useEffect(() => {
if (loading) return;
if (!user) {
router.push("/login");
return;
}
if (user.role !== "ADMIN" && user.role !== "MODERATOR") {
if (!hasDashboardAccess) {
router.push("/dashboard");
}
}, [user, loading, router]);
}, [user, loading, hasDashboardAccess, router]);
if (loading || !user || (user.role !== "ADMIN" && user.role !== "MODERATOR")) {
if (loading || !user || !hasDashboardAccess) {
return null;
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Eye, EyeOff, Trash2 } from "lucide-react";
type Row = {
id: string;
paymentHash: string;
content: string;
authorName: string;
pubkey: string | null;
satsPaid: number;
status: string;
likeCount: number;
createdAt: string;
};
export default function AdminBoardMessagesPage() {
const [rows, setRows] = useState<Row[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
try {
const data = await api.getAdminBoardMessages();
setRows(data);
setError("");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const toggleHide = async (id: string) => {
try {
await api.hideBoardMessage(id);
await load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Hide failed");
}
};
const softDelete = async (id: string) => {
if (!confirm("Mark this message as deleted? It will disappear from the public board.")) return;
try {
await api.deleteBoardMessage(id);
await load();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Delete failed");
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-on-surface/50">Loading board messages</p>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">Message board</h1>
<p className="text-on-surface-variant text-sm max-w-2xl">
Lightning-paid public messages. Hide toggles visibility on the site; delete marks a row as removed
without dropping history.
</p>
{error && <p className="text-error text-sm">{error}</p>}
<div className="overflow-x-auto rounded-xl border border-outline-variant/30">
<table className="w-full text-sm text-left">
<thead className="bg-surface-container-high text-on-surface-variant uppercase text-xs">
<tr>
<th className="px-4 py-3 font-semibold">Content</th>
<th className="px-4 py-3 font-semibold">Author</th>
<th className="px-4 py-3 font-semibold">Sats</th>
<th className="px-4 py-3 font-semibold">Status</th>
<th className="px-4 py-3 font-semibold">Date</th>
<th className="px-4 py-3 font-semibold w-40">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-outline-variant/20">
{rows.map((r) => (
<tr key={r.id} className="bg-surface-container-low hover:bg-surface-container/80">
<td className="px-4 py-3 max-w-md">
<p className="text-on-surface line-clamp-3 whitespace-pre-wrap break-words">{r.content}</p>
</td>
<td className="px-4 py-3 text-on-surface whitespace-nowrap">{r.authorName}</td>
<td className="px-4 py-3 font-mono text-primary">{r.satsPaid}</td>
<td className="px-4 py-3">
<span
className={
r.status === "active"
? "text-green-600 font-medium"
: r.status === "hidden"
? "text-amber-600 font-medium"
: "text-on-surface-variant"
}
>
{r.status}
</span>
</td>
<td className="px-4 py-3 text-on-surface-variant whitespace-nowrap">
{formatDate(r.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => toggleHide(r.id)}
disabled={r.status === "deleted"}
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-surface-container-high text-on-surface text-xs font-medium hover:bg-surface-container disabled:opacity-40"
title={r.status === "hidden" ? "Unhide" : "Hide"}
>
{r.status === "hidden" ? <Eye size={14} /> : <EyeOff size={14} />}
{r.status === "hidden" ? "Unhide" : "Hide"}
</button>
<button
type="button"
onClick={() => softDelete(r.id)}
disabled={r.status === "deleted"}
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg bg-error/15 text-error text-xs font-medium hover:bg-error/25 disabled:opacity-40"
>
<Trash2 size={14} />
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{rows.length === 0 && (
<p className="p-8 text-center text-on-surface-variant">No board messages yet.</p>
)}
</div>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { slugify } from "@/lib/utils";
import { Plus, Pencil, Trash2, X } from "lucide-react";
interface OrganizerForm {
name: string;
slug: string;
}
export default function OrganizersPage() {
const [organizers, setOrganizers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<OrganizerForm>({ name: "", slug: "" });
const [saving, setSaving] = useState(false);
const loadOrganizers = async () => {
try {
const data = await api.getOrganizers();
setOrganizers(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadOrganizers();
}, []);
const openCreate = () => {
setForm({ name: "", slug: "" });
setEditingId(null);
setShowForm(true);
};
const openEdit = (org: any) => {
setForm({ name: org.name, slug: org.slug });
setEditingId(org.id);
setShowForm(true);
};
const handleNameChange = (name: string) => {
setForm({ name, slug: editingId ? form.slug : slugify(name) });
};
const handleSave = async () => {
if (!form.name.trim() || !form.slug.trim()) return;
setSaving(true);
setError("");
try {
if (editingId) {
await api.updateOrganizer(editingId, form);
} else {
await api.createOrganizer(form);
}
setShowForm(false);
setEditingId(null);
await loadOrganizers();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this organizer?")) return;
try {
await api.deleteOrganizer(id);
await loadOrganizers();
} catch (err: any) {
setError(err.message);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading organizers...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Organizers</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity"
>
<Plus size={16} />
Add Organizer
</button>
</div>
<p className="text-on-surface/60 text-sm max-w-2xl">
Organizers appear on public event cards and detail pages. The default is Belgian Bitcoin Embassy;
add other Belgian meetup groups so their events can be listed on this site.
</p>
{error && <p className="text-error text-sm">{error}</p>}
{showForm && (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-on-surface">
{editingId ? "Edit Organizer" : "New Organizer"}
</h2>
<button onClick={() => setShowForm(false)} className="text-on-surface/50 hover:text-on-surface">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input
placeholder="Display name"
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<input
placeholder="URL slug (e.g. antwerp-bitcoin)"
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.name.trim()}
className="px-6 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-6 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
</div>
</div>
)}
<div className="space-y-3">
{organizers.length === 0 ? (
<p className="text-on-surface/50 text-sm">No organizers found.</p>
) : (
organizers.map((org) => (
<div
key={org.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<h3 className="text-on-surface font-semibold">{org.name}</h3>
<p className="text-on-surface/50 text-sm">/events/organizer/{org.slug}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openEdit(org)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(org.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
)}
</div>
</div>
);
}
+7 -3
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
import { formatDate } from "@/lib/utils";
import { Calendar, FileText, Tag, User, Plus, Download, FolderOpen } from "lucide-react";
import Link from "next/link";
@@ -86,7 +86,11 @@ export default function OverviewPage() {
<StatCard icon={Calendar} label="Total Meetups" value={meetups.length} />
<StatCard icon={FileText} label="Blog Posts" value={posts.length} />
<StatCard icon={Tag} label="Categories" value={categories.length} />
<StatCard icon={User} label="Your Role" value={user.role} />
<StatCard
icon={User}
label="Your Role"
value={user.isSuperAdmin ? "SuperAdmin" : user.role}
/>
</div>
{upcomingMeetup && (
@@ -94,7 +98,7 @@ export default function OverviewPage() {
<h2 className="text-lg font-semibold text-on-surface mb-3">Next Upcoming Meetup</h2>
<p className="text-primary font-semibold">{upcomingMeetup.title}</p>
<p className="text-on-surface/60 text-sm mt-1">
{formatDate(upcomingMeetup.date)} · {upcomingMeetup.location}
{formatMeetupCivilDateLong(upcomingMeetup.date)} · {upcomingMeetup.location}
</p>
</div>
)}
+3 -3
View File
@@ -6,7 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { LogIn } from "lucide-react";
export default function AdminPage() {
const { user, loading, login } = useAuth();
const { user, loading, login, isSuperAdmin, permissions } = useAuth();
const router = useRouter();
const [error, setError] = useState("");
const [loggingIn, setLoggingIn] = useState(false);
@@ -14,12 +14,12 @@ export default function AdminPage() {
useEffect(() => {
if (loading) return;
if (!user) return;
if (user.role === "ADMIN" || user.role === "MODERATOR") {
if (isSuperAdmin || permissions.length > 0) {
router.push("/admin/overview");
} else {
router.push("/dashboard");
}
}, [user, loading, router]);
}, [user, loading, isSuperAdmin, permissions, router]);
const handleLogin = async () => {
setError("");
+234
View File
@@ -0,0 +1,234 @@
"use client";
import { Fragment, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { Check, Lock, Save } from "lucide-react";
interface PermissionDef {
key: string;
label: string;
group: string;
}
type Matrix = Record<string, Record<string, boolean>>;
const ROLE_LABELS: Record<string, string> = {
admin: "Admin",
moderator: "Moderator",
writer: "Writer",
};
function buildMatrix(
roles: string[],
perms: PermissionDef[],
granted: Record<string, string[]>
): Matrix {
const matrix: Matrix = {};
for (const role of roles) {
matrix[role] = {};
const set = new Set(granted[role] ?? []);
for (const perm of perms) {
matrix[role][perm.key] = set.has(perm.key);
}
}
return matrix;
}
export default function RolesPage() {
const { can } = useAuth();
const allowed = can("roles.edit_permissions");
const [permissions, setPermissions] = useState<PermissionDef[]>([]);
const [roles, setRoles] = useState<string[]>([]);
const [matrix, setMatrix] = useState<Matrix>({});
const [original, setOriginal] = useState<Matrix>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState("");
const load = async () => {
setLoading(true);
try {
const [registry, current] = await Promise.all([
api.getPermissionRegistry(),
api.getRolePermissions(),
]);
const granted: Record<string, string[]> = {};
for (const r of current.roles) granted[r.role] = r.permissions;
const built = buildMatrix(registry.roles, registry.permissions, granted);
setPermissions(registry.permissions);
setRoles(registry.roles);
setMatrix(built);
setOriginal(built);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (allowed) load();
else setLoading(false);
}, [allowed]);
const groups = useMemo(() => {
const order: string[] = [];
const byGroup: Record<string, PermissionDef[]> = {};
for (const perm of permissions) {
if (!byGroup[perm.group]) {
byGroup[perm.group] = [];
order.push(perm.group);
}
byGroup[perm.group].push(perm);
}
return order.map((group) => ({ group, perms: byGroup[group] }));
}, [permissions]);
const dirtyRoles = useMemo(() => {
return roles.filter((role) =>
permissions.some((perm) => matrix[role]?.[perm.key] !== original[role]?.[perm.key])
);
}, [roles, permissions, matrix, original]);
const toggle = (role: string, key: string) => {
setNotice("");
setMatrix((prev) => ({
...prev,
[role]: { ...prev[role], [key]: !prev[role]?.[key] },
}));
};
const saveAll = async () => {
if (dirtyRoles.length === 0) return;
setSaving(true);
setError("");
setNotice("");
try {
for (const role of dirtyRoles) {
const keys = permissions.filter((p) => matrix[role]?.[p.key]).map((p) => p.key);
await api.updateRolePermissions(role, keys);
}
setOriginal(() => {
const next: Matrix = {};
for (const role of roles) next[role] = { ...matrix[role] };
return next;
});
setNotice("Permissions saved.");
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (!allowed) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">
You do not have permission to manage roles.
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading roles...</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-on-surface">Roles and Permissions</h1>
<p className="text-on-surface/60 text-sm mt-1">
Toggle what each role can do. SuperAdmin always has every permission and
cannot be changed.
</p>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{notice && <p className="text-primary text-sm">{notice}</p>}
<div className="bg-surface-container-low rounded-xl overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="text-left">
<th className="sticky left-0 bg-surface-container-low p-4 font-semibold text-on-surface/70">
Permission
</th>
{roles.map((role) => (
<th key={role} className="p-4 text-center font-semibold text-on-surface/70">
{ROLE_LABELS[role] ?? role}
</th>
))}
<th className="p-4 text-center font-semibold text-on-surface/70">
<span className="inline-flex items-center gap-1">
<Lock size={12} />
SuperAdmin
</span>
</th>
</tr>
</thead>
<tbody>
{groups.map(({ group, perms }) => (
<Fragment key={group}>
<tr>
<td
colSpan={roles.length + 2}
className="bg-surface-container px-4 py-2 text-xs font-bold uppercase tracking-wide text-on-surface/50"
>
{group}
</td>
</tr>
{perms.map((perm) => (
<tr key={perm.key} className="border-b border-surface-container-high/40">
<td className="sticky left-0 bg-surface-container-low p-4">
<div className="text-on-surface">{perm.label}</div>
<div className="text-on-surface/40 text-xs font-mono">{perm.key}</div>
</td>
{roles.map((role) => (
<td key={role} className="p-4 text-center">
<input
type="checkbox"
checked={!!matrix[role]?.[perm.key]}
onChange={() => toggle(role, perm.key)}
className="h-4 w-4 accent-primary cursor-pointer"
aria-label={`${ROLE_LABELS[role] ?? role}: ${perm.label}`}
/>
</td>
))}
<td className="p-4 text-center text-primary/70">
<Check size={16} className="inline" aria-label="Always granted" />
</td>
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={saveAll}
disabled={dirtyRoles.length === 0 || saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Save size={16} />
{saving ? "Saving..." : "Save changes"}
</button>
{dirtyRoles.length > 0 && !saving && (
<span className="text-on-surface/50 text-sm">
Unsaved changes to {dirtyRoles.map((r) => ROLE_LABELS[r] ?? r).join(", ")}
</span>
)}
</div>
</div>
);
}
+10 -1
View File
@@ -40,6 +40,7 @@ export default function AdminSubmissionsPage() {
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [reviewNote, setReviewNote] = useState("");
const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState("");
const loadSubmissions = async () => {
try {
@@ -61,10 +62,17 @@ export default function AdminSubmissionsPage() {
const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => {
setProcessing(true);
setError("");
setSuccess("");
try {
await api.reviewSubmission(id, { status, reviewNote: reviewNote.trim() || undefined });
const res = await api.reviewSubmission(id, {
status,
reviewNote: reviewNote.trim() || undefined,
});
setReviewingId(null);
setReviewNote("");
if (status === "APPROVED" && res?.post) {
setSuccess(`Approved and published "${res.post.title}" to the blog.`);
}
await loadSubmissions();
} catch (err: any) {
setError(err.message);
@@ -87,6 +95,7 @@ export default function AdminSubmissionsPage() {
</div>
{error && <p className="text-error text-sm">{error}</p>}
{success && <p className="text-primary text-sm">{success}</p>}
<div className="flex gap-2">
{TABS.map((tab) => (
+254 -226
View File
@@ -1,16 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { nip19 } from "nostr-tools";
import { api } from "@/lib/api";
import { cn, formatDate } from "@/lib/utils";
import { fetchNostrProfile, type NostrProfile } from "@/lib/nostr";
import { ShieldCheck, ShieldOff, UserPlus } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { useNostrProfile } from "@/hooks/useNostrProfile";
import { NostrAvatar } from "@/components/nostr/NostrAvatar";
import { formatDate } from "@/lib/utils";
import { Lock, Plus } from "lucide-react";
function hexToNpub(hex: string): string {
const ROLE_RANK: Record<string, number> = {
superadmin: 4,
admin: 3,
moderator: 2,
writer: 1,
guest: 0,
};
const ROLE_OPTIONS: { value: string; label: string }[] = [
{ value: "admin", label: "Admin" },
{ value: "moderator", label: "Moderator" },
{ value: "writer", label: "Writer" },
{ value: "guest", label: "Guest (no role)" },
];
function normalizeRole(role: string | null | undefined): string {
return role && ROLE_RANK[role] !== undefined ? role : "guest";
}
function hexToNpub(pubkey: string): string {
if (!pubkey) return "";
// Already an npub (some users are stored in npub form).
if (pubkey.startsWith("npub1")) return pubkey;
try {
return nip19.npubEncode(hex);
return nip19.npubEncode(pubkey);
} catch {
return "";
}
@@ -21,25 +44,173 @@ function shortenNpub(npub: string): string {
return `${npub.slice(0, 14)}...${npub.slice(-10)}`;
}
function profileInitials(profile: NostrProfile | undefined, npub: string): string {
const n = profile?.name || profile?.displayName;
if (n?.trim()) return n.trim().slice(0, 2).toUpperCase();
if (npub.length >= 8) return npub.slice(5, 7).toUpperCase();
return "?";
interface UserRowProps {
user: any;
draft: string;
onDraftChange: (pubkey: string, value: string) => void;
savingPubkey: string | null;
onSaveUsername: (pubkey: string, currentUsername: string | null | undefined) => void;
onCancelUsername: (pubkey: string, stored: string | null | undefined) => void;
hostname: string;
copiedPubkey: string | null;
onCopyNpub: (pubkey: string) => void;
callerRank: number;
isSuperAdmin: boolean;
savingRole: string | null;
onRoleChange: (pubkey: string, role: string) => void;
}
function UserRow({
user,
draft,
onDraftChange,
savingPubkey,
onSaveUsername,
onCancelUsername,
hostname,
copiedPubkey,
onCopyNpub,
callerRank,
isSuperAdmin,
savingRole,
onRoleChange,
}: UserRowProps) {
const { profile, loading: profileLoading } = useNostrProfile(user.pubkey);
const stored = user.username ?? "";
const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase();
const fullNpub = hexToNpub(user.pubkey);
const nostrDisplay = profile?.name || profile?.displayName;
return (
<div className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1 min-w-0 flex gap-4 items-start">
<NostrAvatar pubkey={user.pubkey} size={56} fallbackText={fullNpub} />
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{profileLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => onCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) => onDraftChange(user.pubkey, e.target.value)}
disabled={savingPubkey === user.pubkey}
placeholder="local-part"
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => onSaveUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !draft.trim() || !usernameDirty}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => onCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
{user.isSuperAdmin ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-bold bg-primary-container/20 text-primary">
<Lock size={12} />
SuperAdmin
</span>
) : (
(() => {
const targetRole = normalizeRole(user.role);
const targetRank = ROLE_RANK[targetRole];
const canModify = callerRank > targetRank;
return (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold text-on-surface/50 uppercase tracking-wide">
Role
</span>
<select
value={targetRole}
disabled={!canModify || savingRole === user.pubkey}
onChange={(e) => onRoleChange(user.pubkey, e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
>
{ROLE_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
disabled={!isSuperAdmin && ROLE_RANK[opt.value] >= callerRank}
>
{opt.label}
</option>
))}
</select>
</label>
);
})()
)}
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
</div>
</div>
);
}
export default function UsersPage() {
const { user: currentUser, isSuperAdmin } = useAuth();
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [promotePubkey, setPromotePubkey] = useState("");
const [promoting, setPromoting] = useState(false);
const [savingRole, setSavingRole] = useState<string | null>(null);
const [hostname, setHostname] = useState("");
const [usernameDrafts, setUsernameDrafts] = useState<Record<string, string>>({});
const [savingPubkey, setSavingPubkey] = useState<string | null>(null);
const [nostrByPubkey, setNostrByPubkey] = useState<Record<string, NostrProfile>>({});
const [nostrLoading, setNostrLoading] = useState(false);
const [copiedPubkey, setCopiedPubkey] = useState<string | null>(null);
const [newUserPubkey, setNewUserPubkey] = useState("");
const [addingUser, setAddingUser] = useState(false);
const [addUserSuccess, setAddUserSuccess] = useState("");
const loadUsers = async () => {
try {
@@ -65,32 +236,6 @@ export default function UsersPage() {
setHostname(typeof window !== "undefined" ? window.location.hostname : "");
}, []);
useEffect(() => {
if (users.length === 0) {
setNostrByPubkey({});
setNostrLoading(false);
return;
}
let cancelled = false;
setNostrLoading(true);
setNostrByPubkey({});
(async () => {
const entries = await Promise.all(
users.map(async (u: { pubkey: string }) => {
const profile = await fetchNostrProfile(u.pubkey);
return [u.pubkey, profile] as const;
})
);
if (!cancelled) {
setNostrByPubkey(Object.fromEntries(entries));
setNostrLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [users]);
const handleCopyNpub = async (pubkey: string) => {
const full = hexToNpub(pubkey);
if (!full) return;
@@ -103,39 +248,20 @@ export default function UsersPage() {
}
};
const handlePromote = async () => {
if (!promotePubkey.trim()) return;
setPromoting(true);
const callerRank = isSuperAdmin
? ROLE_RANK.superadmin
: ROLE_RANK[normalizeRole(currentUser?.role)];
const handleRoleChange = async (pubkey: string, role: string) => {
setSavingRole(pubkey);
setError("");
try {
await api.promoteUser(promotePubkey);
setPromotePubkey("");
await api.setUserRole(pubkey, role === "guest" ? null : role);
await loadUsers();
} catch (err: any) {
setError(err.message);
} finally {
setPromoting(false);
}
};
const handleDemote = async (pubkey: string) => {
if (!confirm("Demote this user to regular user?")) return;
setError("");
try {
await api.demoteUser(pubkey);
await loadUsers();
} catch (err: any) {
setError(err.message);
}
};
const handlePromoteUser = async (pubkey: string) => {
setError("");
try {
await api.promoteUser(pubkey);
await loadUsers();
} catch (err: any) {
setError(err.message);
setSavingRole(null);
}
};
@@ -159,6 +285,28 @@ export default function UsersPage() {
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: stored ?? "" }));
};
const handleDraftChange = (pubkey: string, value: string) => {
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: value }));
};
const handleAddUser = async () => {
const value = newUserPubkey.trim();
if (!value) return;
setAddingUser(true);
setError("");
setAddUserSuccess("");
try {
await api.createUser(value);
setNewUserPubkey("");
setAddUserSuccess("User added.");
await loadUsers();
} catch (err: any) {
setError(err.message);
} finally {
setAddingUser(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -174,177 +322,57 @@ export default function UsersPage() {
{error && <p className="text-error text-sm">{error}</p>}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Promote User</h2>
<div className="flex gap-3">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Add User</h2>
<p className="text-on-surface-variant text-xs mb-3">
Pre-register a user by their npub or hex pubkey so you can assign a role before they log in.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<input
placeholder="Pubkey (hex)"
value={promotePubkey}
onChange={(e) => setPromotePubkey(e.target.value)}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1"
placeholder="npub1... or hex pubkey"
value={newUserPubkey}
onChange={(e) => setNewUserPubkey(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddUser();
}}
disabled={addingUser}
className="bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 w-full font-mono text-sm focus:outline-none focus:ring-1 focus:ring-primary/40 flex-1 disabled:opacity-50"
/>
<button
onClick={handlePromote}
disabled={promoting || !promotePubkey.trim()}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
onClick={handleAddUser}
disabled={!newUserPubkey.trim() || addingUser}
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
<UserPlus size={16} />
{promoting ? "Promoting..." : "Promote"}
<Plus size={16} />
{addingUser ? "Adding…" : "Add User"}
</button>
</div>
{addUserSuccess && (
<p className="text-green-400 text-sm mt-3">{addUserSuccess}</p>
)}
</div>
<div className="space-y-3">
{users.length === 0 ? (
<p className="text-on-surface/50 text-sm">No users found.</p>
) : (
users.map((user) => {
const draft = usernameDrafts[user.pubkey] ?? "";
const stored = user.username ?? "";
const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase();
const profile = nostrByPubkey[user.pubkey];
const fullNpub = hexToNpub(user.pubkey);
const nostrDisplay = profile?.name || profile?.displayName;
return (
<div
users.map((user) => (
<UserRow
key={user.pubkey || user.id}
className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div className="flex-1 min-w-0 flex gap-4 items-start">
<div className="shrink-0 w-14 h-14 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface">
{nostrLoading ? (
<span className="text-on-surface/40 text-xs"></span>
) : profile?.picture ? (
<Image
src={profile.picture}
alt={nostrDisplay ? `Avatar: ${nostrDisplay}` : "Nostr profile picture"}
width={56}
height={56}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<span className="font-semibold text-sm" aria-hidden>
{profileInitials(profile, fullNpub)}
</span>
)}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{nostrLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => handleCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) =>
setUsernameDrafts((prev) => ({ ...prev, [user.pubkey]: e.target.value }))
}
disabled={savingPubkey === user.pubkey}
placeholder="local-part"
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => handleSaveUsername(user.pubkey, user.username)}
disabled={
savingPubkey === user.pubkey ||
!draft.trim() ||
!usernameDirty
}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => handleCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
? "bg-primary-container/20 text-primary"
: user.role === "MODERATOR"
? "bg-secondary-container text-on-secondary-container"
: "bg-surface-container-highest text-on-surface/50"
)}
>
{user.role}
</span>
{user.createdAt && (
<span className="text-on-surface/40 text-xs">
Joined {formatDate(user.createdAt)}
</span>
)}
</div>
</div>
</div>
{user.role !== "ADMIN" && (
<div className="flex items-center gap-2">
{user.role !== "MODERATOR" && (
<button
onClick={() => handlePromoteUser(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-primary text-sm transition-colors"
>
<ShieldCheck size={14} />
Promote
</button>
)}
{user.role === "MODERATOR" && (
<button
onClick={() => handleDemote(user.pubkey)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-error text-sm transition-colors"
>
<ShieldOff size={14} />
Demote
</button>
)}
</div>
)}
</div>
);
})
user={user}
draft={usernameDrafts[user.pubkey] ?? ""}
onDraftChange={handleDraftChange}
savingPubkey={savingPubkey}
onSaveUsername={handleSaveUsername}
onCancelUsername={handleCancelUsername}
hostname={hostname}
copiedPubkey={copiedPubkey}
onCopyNpub={handleCopyNpub}
callerRank={callerRank}
isSuperAdmin={isSuperAdmin}
savingRole={savingRole}
onRoleChange={handleRoleChange}
/>
))
)}
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
import { buildBlogMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildBlogMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+179 -103
View File
@@ -3,14 +3,27 @@
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { ArrowLeft, Heart, Send } from "lucide-react";
import ReactMarkdown from "react-markdown";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, type NostrProfile } from "@/lib/nostr";
import {
hasNostrExtension,
getPublicKey,
signEvent,
publishEvent,
shortenNpub,
fetchNostrProfile,
fetchLongformFromRelays,
fetchEventFromRelays,
resolveEventFromRelays,
type NostrProfile,
} from "@/lib/nostr";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import type { Components } from "react-markdown";
import { markdownComponents } from "./markdownComponents";
import { remarkNostr } from "./remarkNostr";
import { NostrAuthor } from "./NostrEmbeds";
interface Post {
id: string;
@@ -18,11 +31,13 @@ interface Post {
title: string;
content: string;
excerpt?: string;
image?: string;
authorName?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
nostrEventId?: string;
naddr?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
}
@@ -33,84 +48,29 @@ interface NostrReply {
created_at: number;
}
const markdownComponents: Components = {
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
),
p: ({ children }) => (
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
),
a: ({ href, children }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
),
li: ({ children }) => (
<li className="leading-relaxed">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<code className={`${className} block`}>
{children}
</code>
);
}
return (
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
{children}
</pre>
),
img: ({ src, alt }) => (
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
),
hr: () => <hr className="border-surface-container-high my-8" />,
table: ({ children }) => (
<div className="overflow-x-auto mb-6">
<table className="w-full text-left text-on-surface-variant">{children}</table>
</div>
),
th: ({ children }) => (
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2">{children}</td>
),
};
// Builds a renderable Post from a raw long-form Nostr event, used when a slug is
// a NIP-19 reference (naddr/nevent/note) that was never indexed by the backend.
function postFromEvent(event: any, slug: string): Post {
const tag = (name: string): string | undefined =>
event.tags?.find((t: string[]) => t[0] === name)?.[1];
const publishedAtSec = Number(tag("published_at")) || event.created_at;
return {
id: event.id,
slug,
title: tag("title") || "Untitled",
content: event.content || "",
excerpt: tag("summary"),
image: tag("image"),
authorPubkey: event.pubkey,
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
nostrEventId: event.id,
naddr: slug.startsWith("naddr") ? slug : undefined,
categories: (event.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => ({ category: { id: t[1], name: t[1], slug: t[1] } })),
};
}
function ArticleSkeleton() {
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
@@ -136,6 +96,18 @@ function ArticleSkeleton() {
);
}
function RelayLoading() {
return (
<div className="flex items-center justify-center gap-3 py-16 text-on-surface-variant">
<span
className="inline-block w-5 h-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin"
aria-hidden
/>
<span className="text-sm font-medium">Fetching from Nostr relays</span>
</div>
);
}
export default function BlogPostClient({ slug }: { slug: string }) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
@@ -147,6 +119,14 @@ export default function BlogPostClient({ slug }: { slug: string }) {
const [hasNostr, setHasNostr] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
const [liveContent, setLiveContent] = useState<string | null>(null);
const [liveImage, setLiveImage] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
const [contentError, setContentError] = useState(false);
const [retryKey, setRetryKey] = useState(0);
const retry = useCallback(() => setRetryKey((k) => k + 1), []);
useEffect(() => {
setHasNostr(hasNostrExtension());
@@ -154,21 +134,77 @@ export default function BlogPostClient({ slug }: { slug: string }) {
useEffect(() => {
if (!slug) return;
let cancelled = false;
setLoading(true);
setError(null);
setLiveContent(null);
setLiveImage(null);
setPost(null);
setAuthorProfile(null);
setContentError(false);
setResolvingFromRelays(false);
// Loads the author's profile, then hydrates the article body from relays
// when only metadata is present (indexed posts store an empty body).
const hydrate = (data: Post) => {
if (cancelled) return;
setPost(data);
setLoading(false);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => !cancelled && setAuthorProfile(profile))
.catch(() => {});
}
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
setLoadingContent(true);
setContentError(false);
const fetchPromise = data.naddr
? fetchLongformFromRelays(data.naddr)
: fetchEventFromRelays(data.nostrEventId!);
fetchPromise
.then((event) => {
if (cancelled) return;
if (event?.content) {
setLiveContent(event.content);
// Pull the longform header image (`image` tag) for display.
const img = event.tags?.find((t: string[]) => t[0] === "image")?.[1];
if (img) setLiveImage(img);
} else {
setContentError(true); // not found / timed out
}
})
.catch(() => !cancelled && setContentError(true))
.finally(() => !cancelled && setLoadingContent(false));
}
};
api
.getPost(slug)
.then((data) => {
setPost(data);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => setAuthorProfile(profile))
.catch(() => {});
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [slug]);
.then((data) => hydrate(data))
.catch(() => {
// Not indexed: treat the slug itself as a NIP-19 reference and resolve
// the long-form note live from relays.
if (cancelled) return;
setLoading(false);
setResolvingFromRelays(true);
resolveEventFromRelays(slug)
.then((event) => {
if (cancelled) return;
setResolvingFromRelays(false);
if (event) hydrate(postFromEvent(event, slug));
else setError("Post not found");
})
.catch((err) => {
if (cancelled) return;
setResolvingFromRelays(false);
setError(err?.message || "Post not found");
});
});
return () => {
cancelled = true;
};
}, [slug, retryKey]);
useEffect(() => {
if (!slug) return;
@@ -233,6 +269,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
}, [comment, post, hasNostr]);
const categories = post?.categories?.map((c) => c.category) || [];
const headerImage = post?.image || liveImage;
return (
<>
@@ -250,9 +287,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
{loading && <ArticleSkeleton />}
{resolvingFromRelays && !post && <RelayLoading />}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
Failed to load post: {error}
<p className="mb-4">Failed to load post: {error}</p>
<button
onClick={retry}
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
>
Try again
</button>
</div>
)}
@@ -288,7 +333,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
/>
)}
<span className="font-medium text-on-surface-variant">
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
{authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
</span>
</div>
)}
@@ -305,13 +350,46 @@ export default function BlogPostClient({ slug }: { slug: string }) {
</div>
</header>
{headerImage && (
<img
src={headerImage}
alt={post.title}
className="w-full rounded-xl mb-12 object-cover max-h-[28rem]"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<article className="mb-16">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{post.content}
</ReactMarkdown>
{loadingContent ? (
<RelayLoading />
) : contentError && !(post.content || liveContent) ? (
<div className="text-center py-12">
<p className="text-on-surface-variant mb-4">
Couldn&apos;t fetch this article from the Nostr relays. It may be
temporarily unavailable.
</p>
<button
onClick={retry}
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
>
Try again
</button>
</div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostr]}
components={markdownComponents}
// Preserve nostr: links (react-markdown strips unknown
// schemes by default), so mentions/notes resolve correctly.
urlTransform={(url) =>
url.startsWith("nostr:") ? url : defaultUrlTransform(url)
}
>
{post.content || liveContent || ""}
</ReactMarkdown>
)}
</article>
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
@@ -369,9 +447,7 @@ export default function BlogPostClient({ slug }: { slug: string }) {
className="bg-surface-container-high rounded-lg p-4"
>
<div className="flex items-center gap-2.5 mb-2">
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
{shortenPubkey(r.pubkey)}
</span>
<NostrAuthor pubkey={r.pubkey} />
<span className="text-on-surface-variant/30">·</span>
<span className="text-xs text-on-surface-variant/50">
{formatDate(new Date(r.created_at * 1000))}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { nip19 } from "nostr-tools";
import {
loadNostrProfile,
resolveEventFromRelays,
shortenNpub,
type NostrProfile,
} from "@/lib/nostr";
function njump(bech32: string): string {
return `https://njump.me/${bech32}`;
}
// Inline @mention chip: avatar + display name, resolved live from the author's
// kind:0 profile. Falls back to a shortened pubkey until (or unless) it loads.
export function NostrMention({ bech32 }: { bech32: string }) {
const pubkey = useMemo(() => {
try {
const d = nip19.decode(bech32);
if (d.type === "npub") return d.data as string;
if (d.type === "nprofile") return (d.data as { pubkey: string }).pubkey;
} catch {}
return null;
}, [bech32]);
const [profile, setProfile] = useState<NostrProfile | null>(null);
useEffect(() => {
if (!pubkey) return;
let cancelled = false;
loadNostrProfile(pubkey)
.then((p) => !cancelled && setProfile(p))
.catch(() => {});
return () => {
cancelled = true;
};
}, [pubkey]);
// Show the username; fall back to a shortened npub only when no profile.
const name =
profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
return (
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 align-middle text-primary hover:underline font-medium no-underline"
>
{profile?.picture && (
<img
src={profile.picture}
alt=""
className="w-5 h-5 rounded-full object-cover bg-surface-container-high inline-block"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span>@{name}</span>
</a>
);
}
// Reusable author line (avatar + username) resolved from the author's kind:0
// profile. Falls back to a shortened npub — never the raw hex pubkey. Shared by
// embedded notes and the blog comment list.
export function NostrAuthor({ pubkey }: { pubkey: string }) {
const [profile, setProfile] = useState<NostrProfile | null>(null);
useEffect(() => {
let cancelled = false;
loadNostrProfile(pubkey)
.then((p) => !cancelled && setProfile(p))
.catch(() => {});
return () => {
cancelled = true;
};
}, [pubkey]);
const name = profile?.name || profile?.displayName || shortenNpub(pubkey);
return (
<span className="inline-flex items-center gap-2">
{profile?.picture && (
<img
src={profile.picture}
alt=""
className="w-6 h-6 rounded-full object-cover bg-surface-container-high shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="font-semibold text-on-surface text-sm">{name}</span>
</span>
);
}
// Embedded referenced note (note/nevent/naddr). Renders as a bordered card.
// Uses span/block elements (not <div>) so it stays valid when the reference
// sits inside a markdown paragraph.
export function NostrNote({ bech32 }: { bech32: string }) {
const [event, setEvent] = useState<any>(null);
const [state, setState] = useState<"loading" | "done" | "error">("loading");
useEffect(() => {
let cancelled = false;
setState("loading");
resolveEventFromRelays(bech32)
.then((ev) => {
if (cancelled) return;
if (ev) {
setEvent(ev);
setState("done");
} else {
setState("error");
}
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [bech32]);
return (
<span className="block my-4 rounded-xl border border-surface-container-high bg-surface-container-low p-4">
{state === "loading" && (
<span className="flex items-center gap-2 text-on-surface-variant text-sm">
<span className="inline-block w-4 h-4 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
Loading note
</span>
)}
{state === "error" && (
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm break-all"
>
View referenced note on njump.me
</a>
)}
{state === "done" && event && (
<span className="block">
<span className="flex items-center justify-between mb-3">
<NostrAuthor pubkey={event.pubkey} />
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-on-surface-variant/60 hover:text-primary"
>
View note
</a>
</span>
<span className="block whitespace-pre-wrap break-words text-on-surface-variant text-sm leading-relaxed">
{(event.content || "").slice(0, 1000)}
{(event.content || "").length > 1000 ? "…" : ""}
</span>
</span>
)}
</span>
);
}
// Dispatches a `nostr:<bech32>` reference to the right embed by entity type.
export function NostrEntity({ bech32 }: { bech32: string }) {
let type: string;
try {
type = nip19.decode(bech32).type;
} catch {
return <>nostr:{bech32}</>;
}
if (type === "npub" || type === "nprofile") return <NostrMention bech32={bech32} />;
if (type === "note" || type === "nevent" || type === "naddr") return <NostrNote bech32={bech32} />;
return <>nostr:{bech32}</>;
}
@@ -0,0 +1,89 @@
import type { Components } from "react-markdown";
import { NostrEntity } from "./NostrEmbeds";
// Shared markdown renderers for blog article bodies. The `remarkNostr` plugin
// rewrites NIP-27 `nostr:` references into links with that scheme, which the
// `a` renderer below swaps for live profile/note embeds.
export const markdownComponents: Components = {
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
),
p: ({ children }) => (
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
),
a: ({ href, children }) => {
if (href?.startsWith("nostr:")) {
return <NostrEntity bech32={href.slice("nostr:".length)} />;
}
return (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
},
ul: ({ children }) => (
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
),
li: ({ children }) => (
<li className="leading-relaxed">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<code className={`${className} block`}>
{children}
</code>
);
}
return (
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
{children}
</pre>
),
img: ({ src, alt }) => (
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
),
hr: () => <hr className="border-surface-container-high my-8" />,
table: ({ children }) => (
<div className="overflow-x-auto mb-6">
<table className="w-full text-left text-on-surface-variant">{children}</table>
</div>
),
th: ({ children }) => (
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2">{children}</td>
),
};
+3 -1
View File
@@ -30,7 +30,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
post.excerpt ||
`Read "${post.title}" on the Belgian Bitcoin Embassy blog.`;
const author = post.authorName || "Belgian Bitcoin Embassy";
const ogImageUrl = `/og?title=${encodeURIComponent(post.title)}&type=blog`;
// Prefer the post's own header image (Nostr `image` tag); fall back to a
// generated OG card so every post still gets a real preview image.
const ogImageUrl = post.image || `/og?title=${encodeURIComponent(post.title)}&type=blog`;
return {
title: post.title,
+64
View File
@@ -0,0 +1,64 @@
import { nip19 } from "nostr-tools";
// Matches NIP-27 `nostr:` references (and bare bech32 entities) embedded in
// article text: npub/nprofile (mentions) and note/nevent/naddr (notes).
const NOSTR_RE =
/(?:nostr:)?((?:npub|nprofile|note|nevent|naddr)1[023456789acdefghjklmnpqrstuvwxyz]+)/gi;
function isValidEntity(bech32: string): boolean {
try {
const { type } = nip19.decode(bech32);
return ["npub", "nprofile", "note", "nevent", "naddr"].includes(type);
} catch {
return false;
}
}
// Splits a plain-text value into text + `link` nodes, where each link's url is
// `nostr:<bech32>`. The markdown `a` renderer detects that scheme and swaps in
// the live profile/note component.
function splitText(value: string): any[] {
const out: any[] = [];
let last = 0;
NOSTR_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = NOSTR_RE.exec(value)) !== null) {
const bech32 = m[1];
if (!isValidEntity(bech32)) continue;
if (m.index > last) {
out.push({ type: "text", value: value.slice(last, m.index) });
}
out.push({
type: "link",
url: `nostr:${bech32}`,
children: [{ type: "text", value: m[0] }],
});
last = m.index + m[0].length;
}
if (out.length === 0) return [{ type: "text", value }];
if (last < value.length) out.push({ type: "text", value: value.slice(last) });
return out;
}
// Remark plugin: walk the mdast tree and replace `nostr:` tokens inside text
// nodes. Skips code and existing links so identifiers there are left untouched.
export function remarkNostr() {
return (tree: any) => {
const walk = (node: any) => {
if (!node || !Array.isArray(node.children)) return;
const next: any[] = [];
for (const child of node.children) {
if (child.type === "text") {
next.push(...splitText(child.value));
continue;
}
if (child.type !== "link" && child.type !== "inlineCode" && child.type !== "code") {
walk(child);
}
next.push(child);
}
node.children = next;
};
walk(tree);
};
}
+40 -252
View File
@@ -1,109 +1,45 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import {
BlogIndex,
type BlogPost,
type BlogCategory,
} from "@/components/public/BlogIndex";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
interface Post {
id: string;
slug: string;
title: string;
excerpt?: string;
content?: string;
author?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { id: string; name: string; slug: string }[];
featured?: boolean;
const LIMIT = 9;
// Render at request time so the first page of posts is in the HTML for crawlers.
export const dynamic = "force-dynamic";
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
try {
const res = await fetch(apiUrl(path), { cache: "no-store" });
if (!res.ok) return fallback;
return (await res.json()) as T;
} catch {
return fallback;
}
}
interface Category {
id: string;
name: string;
slug: string;
}
function PostCardSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
<div className="p-6 space-y-4">
<div className="flex gap-2">
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
</div>
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
<div className="space-y-2">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
</div>
<div className="flex justify-between items-center pt-4">
<div className="h-4 w-32 bg-surface-container-high rounded" />
<div className="h-4 w-24 bg-surface-container-high rounded" />
</div>
</div>
</div>
);
}
function FeaturedPostSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse mb-12">
<div className="p-8 md:p-12 space-y-4">
<div className="h-5 w-24 bg-surface-container-high rounded-full" />
<div className="h-10 w-2/3 bg-surface-container-high rounded" />
<div className="space-y-2 max-w-2xl">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-1/2 bg-surface-container-high rounded" />
</div>
<div className="h-4 w-48 bg-surface-container-high rounded" />
</div>
</div>
);
}
export default function BlogPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const limit = 9;
useEffect(() => {
api.getCategories().then(setCategories).catch(() => {});
}, []);
useEffect(() => {
setLoading(true);
setError(null);
api
.getPosts({
category: activeCategory === "all" ? undefined : activeCategory,
page,
limit,
})
.then(({ posts: data, total: t }) => {
setPosts(data);
setTotal(t);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [activeCategory, page]);
const totalPages = Math.ceil(total / limit);
const featured = posts.find((p) => p.featured);
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
export default async function BlogPage() {
const [{ posts, total }, categories] = await Promise.all([
fetchJson<{ posts: BlogPost[]; total: number }>(`/posts?page=1&limit=${LIMIT}`, {
posts: [],
total: 0,
}),
fetchJson<BlogCategory[]>("/categories", []),
]);
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Blog", href: "/blog" },
]}
/>
<Navbar />
<div className="min-h-screen">
@@ -121,160 +57,12 @@ export default function BlogPage() {
</div>
</header>
<div className="max-w-7xl mx-auto px-8 mb-12">
<div className="flex flex-wrap gap-3">
<button
onClick={() => { setActiveCategory("all"); setPage(1); }}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === "all"
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => { setActiveCategory(cat.slug); setPage(1); }}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.slug
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="max-w-7xl mx-auto px-8 pb-24">
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
Failed to load posts: {error}
</div>
)}
{loading ? (
<>
<FeaturedPostSkeleton />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
</>
) : posts.length === 0 ? (
<div className="text-center py-24">
<p className="text-2xl font-bold text-on-surface-variant mb-2">
No posts yet
</p>
<p className="text-on-surface-variant/60">
Check back soon for curated Bitcoin content.
</p>
</div>
) : (
<>
{featured && page === 1 && (
<Link
href={`/blog/${featured.slug}`}
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
>
<div className="p-8 md:p-12">
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
Featured
</span>
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
{featured.title}
</h2>
{featured.excerpt && (
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
{featured.excerpt}
</p>
)}
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
{featured.author && <span>{featured.author}</span>}
{featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div>
</div>
</Link>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{regularPosts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((cat) => (
<span
key={cat.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{cat.name}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
{post.author && <span>{post.author}</span>}
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
{(post.publishedAt || post.createdAt) && (
<span>
{formatDate(post.publishedAt || post.createdAt!)}
</span>
)}
</div>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
Read <ArrowRight size={12} />
</span>
</div>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-16">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowLeft size={16} /> Previous
</button>
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
Next <ChevronRight size={16} />
</button>
</div>
)}
</>
)}
</div>
<BlogIndex
initialPosts={Array.isArray(posts) ? posts : []}
initialTotal={total ?? 0}
categories={Array.isArray(categories) ? categories : []}
limit={LIMIT}
/>
</div>
<Footer />
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Message Board — Pay with Lightning",
description:
"Post a public message on the Belgian Bitcoin Embassy board. Pay a small Lightning invoice via LNbits to publish.",
openGraph: {
title: "Message Board — Belgian Bitcoin Embassy",
description: "Pay with Lightning to post a message.",
},
alternates: { canonical: "/board" },
};
export default function BoardLayout({ children }: { children: React.ReactNode }) {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Board", href: "/board" },
]}
/>
{children}
</>
);
}
+449
View File
@@ -0,0 +1,449 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import { QRCodeSVG } from "qrcode.react";
import { nip19 } from "nostr-tools";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { Button } from "@/components/ui/Button";
import { api } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { formatDate } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { shortenPubkey } from "@/lib/nostr";
import { Copy, Check, Heart, Zap } from "lucide-react";
type BoardMessage = {
id: string;
paymentHash: string;
content: string;
authorName: string;
pubkey: string | null;
profilePic: string | null;
satsPaid: number;
likeCount: number;
createdAt: string;
};
type PayPhase = "idle" | "polling" | "confirming" | "paid";
const MAX_LEN = 300;
function BoardAvatar({
picture,
name,
size = 40,
}: {
picture?: string | null;
name: string;
size?: number;
}) {
const [err, setErr] = useState(false);
const initial = name.slice(0, 1).toUpperCase() || "?";
if (picture && !err) {
return (
<Image
src={picture}
alt=""
width={size}
height={size}
className="rounded-full object-cover shrink-0"
style={{ width: size, height: size }}
onError={() => setErr(true)}
unoptimized
/>
);
}
return (
<div
className="rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-sm shrink-0"
style={{ width: size, height: size }}
>
{initial}
</div>
);
}
export default function BoardPage() {
const { user } = useAuth();
const [config, setConfig] = useState<{
priceSats: number;
bbeZapPubkey: string | null;
bbeZapAddress: string | null;
} | null>(null);
const [messages, setMessages] = useState<BoardMessage[]>([]);
const [content, setContent] = useState("");
const [guestName, setGuestName] = useState("");
const [postAsAnon, setPostAsAnon] = useState(false);
const [payPhase, setPayPhase] = useState<PayPhase>("idle");
const [payError, setPayError] = useState("");
const [invoice, setInvoice] = useState<{ pr: string; hash: string } | null>(null);
const [copied, setCopied] = useState(false);
const [localLikes, setLocalLikes] = useState<Record<string, number>>({});
const [highlightPaymentHash, setHighlightPaymentHash] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadMessages = useCallback(async () => {
const list = await api.getBoardMessages();
setMessages(list as BoardMessage[]);
}, []);
const loadConfig = useCallback(async () => {
const c = await api.getBoardConfig();
setConfig(c);
}, []);
useEffect(() => {
loadConfig().catch(() => {});
}, [loadConfig]);
useEffect(() => {
loadMessages().catch(() => {});
const t = setInterval(() => {
loadMessages().catch(() => {});
}, 12_000);
return () => clearInterval(t);
}, [loadMessages]);
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const displayName = user?.name || user?.displayName || "";
const npub = user?.pubkey ? nip19.npubEncode(user.pubkey) : "";
const shortNpub = npub.length > 20 ? `${npub.slice(0, 14)}${npub.slice(-12)}` : npub;
const handlePayAndPost = async () => {
setPayError("");
const trimmed = content.trim();
if (!trimmed) {
setPayError("Write a message first.");
return;
}
if (trimmed.length > MAX_LEN) {
setPayError(`Message must be ${MAX_LEN} characters or fewer.`);
return;
}
try {
const body: Parameters<typeof api.createBoardInvoice>[0] = { content: trimmed };
if (user?.pubkey && !postAsAnon) {
body.pubkey = user.pubkey;
body.name = displayName || undefined;
body.profilePic = user.picture;
} else {
const n = guestName.trim();
if (n) body.name = n;
}
body.postAsAnon = !!(user?.pubkey && postAsAnon);
const inv = await api.createBoardInvoice(body);
setHighlightPaymentHash(inv.payment_hash);
setInvoice({ pr: inv.payment_request, hash: inv.payment_hash });
setPayPhase("polling");
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const status = await api.getBoardPaymentStatus(inv.payment_hash);
if (!status.paid) return;
// Payment detected — try to ensure the message row exists
setPayPhase("confirming");
setInvoice(null);
if (!status.messageCreated) {
// Webhook may not have fired yet; call confirm to create the row
await api.confirmBoardPayment(inv.payment_hash).catch(() => {});
}
// Poll until the message actually appears in the list (max ~30s)
let found = false;
for (let attempt = 0; attempt < 15; attempt++) {
const list = await api.getBoardMessages();
setMessages(list as BoardMessage[]);
if (list.some((m) => m.paymentHash === inv.payment_hash)) {
found = true;
break;
}
// Try confirm again if first attempt didn't work
if (attempt === 2) {
await api.confirmBoardPayment(inv.payment_hash).catch(() => {});
}
await new Promise((r) => setTimeout(r, 2000));
}
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = null;
if (found) {
setPayPhase("paid");
setContent("");
setGuestName("");
setPostAsAnon(false);
setTimeout(() => {
setPayPhase("idle");
setHighlightPaymentHash(null);
}, 4000);
} else {
setPayPhase("idle");
setPayError("Payment received but message is delayed — it will appear shortly.");
}
} catch {
/* keep polling */
}
}, 2000);
} catch (e: unknown) {
setPayPhase("idle");
setPayError(e instanceof Error ? e.message : "Could not create invoice.");
}
};
const copyPr = async () => {
if (!invoice?.pr) return;
await navigator.clipboard.writeText(invoice.pr);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const bumpLike = async (msg: BoardMessage) => {
if (!user) return;
try {
const { likeCount } = await api.likeBoardMessage(msg.id);
setLocalLikes((prev) => ({ ...prev, [msg.id]: likeCount }));
} catch (e: unknown) {
setPayError(e instanceof Error ? e.message : "Like failed");
}
};
const handleZap = async (msg: BoardMessage) => {
const zapAddress = config?.bbeZapAddress;
const fallbackPub = config?.bbeZapPubkey;
try {
const w = window as unknown as {
nostr?: { zap?: (args: Record<string, unknown>) => Promise<unknown> };
};
if (msg.pubkey && w.nostr?.zap) {
await w.nostr.zap({
pubkey: msg.pubkey,
amount: 21,
comment: "BBE board zap",
});
return;
}
} catch {
/* fall through */
}
if (msg.pubkey) {
window.open(`https://njump.me/${nip19.npubEncode(msg.pubkey)}`, "_blank", "noopener,noreferrer");
return;
}
if (zapAddress) {
const addr = zapAddress.replace(/^lightning:/i, "");
window.location.href = `lightning:${addr}`;
return;
}
if (fallbackPub) {
try {
window.open(
`https://njump.me/${nip19.npubEncode(fallbackPub)}`,
"_blank",
"noopener,noreferrer"
);
} catch {
setPayError("Invalid BOARD_ZAP_PUBKEY on server (expected hex pubkey).");
}
return;
}
setPayError("Zap: configure BOARD_ZAP_LN_ADDRESS or BOARD_ZAP_PUBKEY on the server.");
};
const len = content.length;
return (
<>
<Navbar />
<div className="min-h-screen pb-20">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-10">
<h1 className="text-4xl font-black mb-2">Message board</h1>
<p className="text-on-surface-variant text-lg mb-10">
Pay {config?.priceSats ?? "…"} sats via Lightning to post. Messages are public and moderated.
</p>
<section className="rounded-2xl border border-outline-variant/30 bg-surface-container-low p-6 mb-12">
<h2 className="text-lg font-bold text-on-surface mb-4">Post a message</h2>
{user && !postAsAnon ? (
<div className="flex items-center gap-3 mb-4 p-3 rounded-xl bg-surface-container">
<BoardAvatar picture={user.picture} name={displayName || "You"} />
<div className="min-w-0">
<p className="font-semibold text-on-surface truncate">{displayName || "Nostr user"}</p>
<p className="text-xs font-mono text-on-surface-variant truncate" title={npub}>
{shortNpub}
</p>
</div>
</div>
) : (
<div className="space-y-3 mb-4">
{user && (
<label className="flex items-center gap-2 text-sm text-on-surface-variant cursor-pointer">
<input
type="checkbox"
checked={postAsAnon}
onChange={(e) => setPostAsAnon(e.target.checked)}
className="rounded border-outline-variant"
/>
Post as anon (hide Nostr profile)
</label>
)}
{!user || postAsAnon ? (
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1">
Name (optional)
</label>
<input
type="text"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
placeholder="Guest"
maxLength={80}
className="w-full rounded-lg border border-outline-variant bg-surface px-3 py-2 text-on-surface"
/>
</div>
) : null}
</div>
)}
<textarea
value={content}
onChange={(e) => setContent(e.target.value.slice(0, MAX_LEN))}
placeholder="Whats on your mind?"
rows={4}
maxLength={MAX_LEN}
className="w-full rounded-xl border border-outline-variant bg-surface px-4 py-3 text-on-surface placeholder:text-on-surface-variant/50 resize-y min-h-[120px]"
/>
<div className="flex justify-between items-center mt-2 text-sm text-on-surface-variant">
<span>
{len}/{MAX_LEN}
</span>
{payPhase === "polling" && (
<span className="text-primary font-medium animate-pulse">Awaiting payment</span>
)}
{payPhase === "confirming" && (
<span className="text-primary font-medium animate-pulse">Payment received publishing</span>
)}
{payPhase === "paid" && <span className="text-green-500 font-medium">Posted!</span>}
</div>
{payError && <p className="text-error text-sm mt-3">{payError}</p>}
<div className="mt-4 flex flex-wrap gap-3">
<Button
variant="primary"
size="md"
onClick={handlePayAndPost}
disabled={payPhase === "polling" || payPhase === "confirming" || !content.trim()}
>
Pay & post
</Button>
</div>
{invoice && payPhase === "polling" && (
<div className="mt-8 p-4 rounded-xl bg-surface-container border border-outline-variant/40">
<p className="text-sm text-on-surface-variant mb-3">Scan or copy the Lightning invoice:</p>
<div className="flex flex-col sm:flex-row gap-6 items-start">
<div className="bg-white p-3 rounded-lg shrink-0">
<QRCodeSVG value={invoice.pr} size={180} level="M" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<button
type="button"
onClick={copyPr}
className="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{copied ? "Copied" : "Copy invoice"}
</button>
<p className="text-xs font-mono break-all text-on-surface-variant max-h-32 overflow-y-auto">
{invoice.pr}
</p>
</div>
</div>
</div>
)}
</section>
<section>
<h2 className="text-xl font-bold mb-4">Messages</h2>
<ul className="space-y-4">
{messages.map((m) => {
const likes = localLikes[m.id] ?? m.likeCount;
const isNew = highlightPaymentHash && m.paymentHash === highlightPaymentHash;
return (
<li
key={m.id}
className={cn(
"rounded-2xl border p-5 transition-shadow duration-500",
isNew
? "border-primary bg-primary-container/10 shadow-lg shadow-primary/20"
: "border-outline-variant/30 bg-surface-container-low"
)}
>
<div className="flex gap-3">
<BoardAvatar picture={m.profilePic} name={m.authorName} />
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-baseline gap-2 mb-1">
<span className="font-bold text-on-surface">{m.authorName}</span>
<span className="text-xs text-on-surface-variant">{formatDate(m.createdAt)}</span>
<span className="text-xs font-mono text-primary"> {m.satsPaid} sats</span>
</div>
<p className="text-on-surface whitespace-pre-wrap break-words">{m.content}</p>
<div className="flex flex-wrap gap-2 mt-4">
{m.pubkey && user ? (
<button
type="button"
onClick={() => bumpLike(m)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface hover:bg-surface-container transition-colors"
>
<Heart size={16} className="text-red-400" />
{likes}
</button>
) : m.pubkey ? (
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface-variant">
<Heart size={16} className="text-red-400/70" />
{likes}
</span>
) : null}
<button
type="button"
onClick={() => handleZap(m)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-surface-container-high text-on-surface hover:bg-surface-container transition-colors"
>
<Zap size={16} className="text-amber-400" />
Zap
</button>
</div>
</div>
</div>
</li>
);
})}
</ul>
{messages.length === 0 && (
<p className="text-on-surface-variant text-center py-12">No messages yet be the first.</p>
)}
</section>
</div>
</div>
<Footer />
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildCommunityMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildCommunityMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+12 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Community - Connect with Belgian Bitcoiners",
@@ -13,5 +14,15 @@ export const metadata: Metadata = {
};
export default function CommunityLayout({ children }: { children: React.ReactNode }) {
return children;
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Community", href: "/community" },
]}
/>
{children}
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildContactMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildContactMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+9 -52
View File
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { Send, Zap, ExternalLink } from "lucide-react";
import { ContactChannelGrid } from "@/components/public/ContactChannelGrid";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Contact Us",
@@ -19,6 +19,12 @@ export const metadata: Metadata = {
export default function ContactPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Contact", href: "/contact" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
@@ -28,56 +34,7 @@ export default function ContactPage() {
decentralized community there is no central office or email inbox.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<a
href="https://t.me/belgianbitcoinembassy"
target="_blank"
rel="noopener noreferrer"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Send size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">Telegram</h2>
<p className="text-on-surface-variant text-sm">
Join our Telegram group for quick questions and community chat.
</p>
</a>
<a
href="#"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Zap size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">Nostr</h2>
<p className="text-on-surface-variant text-sm">
Follow us on Nostr for censorship-resistant communication.
</p>
</a>
<a
href="#"
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<ExternalLink size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">X (Twitter)</h2>
<p className="text-on-surface-variant text-sm">
Follow us on X for announcements and updates.
</p>
</a>
<div className="bg-surface-container-low p-8 rounded-xl">
<h2 className="text-xl font-bold mb-2">Meetups</h2>
<p className="text-on-surface-variant text-sm mb-4">
The best way to connect is in person. Come to our monthly meetup
in Brussels.
</p>
<Link
href="/#meetup"
className="text-primary font-bold text-sm hover:underline"
>
See next meetup
</Link>
</div>
</div>
<ContactChannelGrid />
</div>
</div>
<Footer />
+1 -4
View File
@@ -16,9 +16,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
router.push("/login");
return;
}
if (user.role === "ADMIN" || user.role === "MODERATOR") {
router.push("/admin/overview");
}
}, [user, loading, router]);
if (loading) {
@@ -33,7 +30,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
}
if (!user || user.role === "ADMIN" || user.role === "MODERATOR") {
if (!user) {
return null;
}
+265 -56
View File
@@ -2,10 +2,10 @@
import { useState, useEffect, useCallback, useRef } from "react";
import Image from "next/image";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign } from "lucide-react";
import { Send, FileText, Clock, CheckCircle, XCircle, Plus, User, Loader2, AtSign, Radio, Trash2, Download, Eye, Pencil } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
import { shortenPubkey, fetchEventFromRelays, fetchLongformFromRelays } from "@/lib/nostr";
import { formatDate } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
@@ -37,7 +37,15 @@ const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; classNa
},
};
type Tab = "submissions" | "profile";
interface UserRelay {
id: string;
url: string;
read: boolean;
write: boolean;
createdAt: string;
}
type Tab = "submissions" | "profile" | "relays";
type UsernameStatus =
| { state: "idle" }
@@ -53,9 +61,7 @@ export default function DashboardPage() {
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [loadingSubs, setLoadingSubs] = useState(true);
const [showForm, setShowForm] = useState(false);
const [title, setTitle] = useState("");
const [eventId, setEventId] = useState("");
const [naddr, setNaddr] = useState("");
const [noteInput, setNoteInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [formSuccess, setFormSuccess] = useState("");
@@ -69,6 +75,15 @@ export default function DashboardPage() {
const [hostname, setHostname] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Relay state
const [userRelays, setUserRelays] = useState<UserRelay[]>([]);
const [loadingRelays, setLoadingRelays] = useState(true);
const [newRelayUrl, setNewRelayUrl] = useState("");
const [addingRelay, setAddingRelay] = useState(false);
const [importingNip65, setImportingNip65] = useState(false);
const [relayError, setRelayError] = useState("");
const [relaySuccess, setRelaySuccess] = useState("");
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
useEffect(() => {
@@ -92,35 +107,72 @@ export default function DashboardPage() {
}
}, []);
const loadRelays = useCallback(async () => {
try {
const data = await api.getUserRelays();
setUserRelays(data);
} catch {
// Silently handle
} finally {
setLoadingRelays(false);
}
}, []);
useEffect(() => {
loadSubmissions();
}, [loadSubmissions]);
loadRelays();
}, [loadSubmissions, loadRelays]);
const extractTitle = (event: any): string => {
const titleTag = event?.tags?.find((t: string[]) => t[0] === "title");
return titleTag?.[1]?.trim() || "Untitled";
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
setFormSuccess("");
if (!title.trim()) {
setFormError("Title is required");
return;
}
if (!eventId.trim() && !naddr.trim()) {
setFormError("Either an Event ID or naddr is required");
const input = noteInput.trim();
if (!input) {
setFormError("A Note ID or naddr is required");
return;
}
setSubmitting(true);
try {
await api.createSubmission({
title: title.trim(),
eventId: eventId.trim() || undefined,
naddr: naddr.trim() || undefined,
});
setFormSuccess("Submission sent for review!");
setTitle("");
setEventId("");
setNaddr("");
let payload: { title: string; eventId?: string; naddr?: string };
if (input.startsWith("naddr1")) {
const event = await fetchLongformFromRelays(input);
if (!event) {
throw new Error("Could not find that article on the relays");
}
payload = { title: extractTitle(event), naddr: input };
} else {
let hexId = input;
if (input.startsWith("note1")) {
try {
const { nip19 } = await import("nostr-tools");
const decoded = nip19.decode(input);
if (decoded.type !== "note") throw new Error();
hexId = decoded.data as string;
} catch {
throw new Error("Invalid note id");
}
} else if (!/^[0-9a-f]{64}$/i.test(input)) {
throw new Error("Enter a valid note id, naddr, or hex event id");
}
const event = await fetchEventFromRelays(hexId);
if (!event) {
throw new Error("Could not find that note on the relays");
}
payload = { title: extractTitle(event), eventId: hexId };
}
await api.createSubmission(payload);
setFormSuccess(`Submission "${payload.title}" sent for review!`);
setNoteInput("");
setShowForm(false);
await loadSubmissions();
} catch (err: any) {
@@ -130,6 +182,63 @@ export default function DashboardPage() {
}
};
const handleAddRelay = async (e: React.FormEvent) => {
e.preventDefault();
setRelayError("");
setRelaySuccess("");
const url = newRelayUrl.trim();
if (!url) {
setRelayError("Relay URL is required");
return;
}
if (!url.startsWith("wss://") && !url.startsWith("ws://")) {
setRelayError("URL must start with wss:// or ws://");
return;
}
setAddingRelay(true);
try {
await api.addUserRelay({ url });
setNewRelayUrl("");
setRelaySuccess("Relay added");
await loadRelays();
} catch (err: any) {
setRelayError(err.message || "Failed to add relay");
} finally {
setAddingRelay(false);
}
};
const handleRemoveRelay = async (id: string) => {
setRelayError("");
try {
await api.removeUserRelay(id);
setUserRelays((prev) => prev.filter((r) => r.id !== id));
} catch (err: any) {
setRelayError(err.message || "Failed to remove relay");
}
};
const handleImportNip65 = async () => {
setRelayError("");
setRelaySuccess("");
setImportingNip65(true);
try {
const result = await api.importNip65Relays();
if (result.imported > 0) {
setRelaySuccess(`Imported ${result.imported} relay(s) from your NIP-65 list`);
await loadRelays();
} else {
setRelayError(result.message || "No NIP-65 relay list found for your pubkey");
}
} catch (err: any) {
setRelayError(err.message || "Failed to import NIP-65 relays");
} finally {
setImportingNip65(false);
}
};
const handleUsernameChange = (value: string) => {
setUsername(value);
setSaveError("");
@@ -247,6 +356,17 @@ export default function DashboardPage() {
<User size={16} />
Profile
</button>
<button
onClick={() => setActiveTab("relays")}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
activeTab === "relays"
? "border-primary text-primary"
: "border-transparent text-on-surface-variant hover:text-on-surface"
}`}
>
<Radio size={16} />
Relays
</button>
</div>
{/* Submissions tab */}
@@ -284,46 +404,20 @@ export default function DashboardPage() {
className="bg-surface-container-low rounded-xl p-6 mb-8 space-y-4"
>
<p className="text-on-surface-variant text-sm mb-2">
Submit a Nostr longform post for moderator review. Provide the
event ID or naddr of the article you&apos;d like published on the
blog.
Submit a Nostr longform post for moderator review. Paste the
note ID or naddr of the article you&apos;d like published on the
blog. The title is pulled automatically from the note.
</p>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Title
Note ID or naddr
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Bitcoin Article"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Nostr Event ID
</label>
<input
type="text"
value={eventId}
onChange={(e) => setEventId(e.target.value)}
placeholder="note1... or hex event id"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Or naddr
</label>
<input
type="text"
value={naddr}
onChange={(e) => setNaddr(e.target.value)}
placeholder="naddr1..."
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
placeholder="naddr1... or note1... or hex event id"
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
</div>
@@ -341,7 +435,7 @@ export default function DashboardPage() {
>
<span className="flex items-center gap-2">
<Send size={16} />
{submitting ? "Submitting..." : "Submit for Review"}
{submitting ? "Fetching & submitting..." : "Submit for Review"}
</span>
</Button>
<Button
@@ -516,6 +610,121 @@ export default function DashboardPage() {
</form>
</section>
)}
{/* Relays tab */}
{activeTab === "relays" && (
<section>
<h2 className="text-xl font-bold text-on-surface mb-2">Your Relays</h2>
<p className="text-on-surface-variant text-sm mb-8">
Manage the Nostr relays used for your interactions on this site.
These relays are used when publishing events and fetching your content.
You can also import your relay list from your Nostr profile (NIP-65).
</p>
{relaySuccess && (
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm mb-6">
{relaySuccess}
</div>
)}
{relayError && (
<div className="bg-error/10 text-error rounded-lg px-4 py-3 text-sm mb-6">
{relayError}
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 mb-8">
<form onSubmit={handleAddRelay} className="flex-1 flex gap-3">
<input
type="text"
value={newRelayUrl}
onChange={(e) => setNewRelayUrl(e.target.value)}
placeholder="wss://relay.example.com"
className="flex-1 bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<Button
variant="primary"
size="md"
type="submit"
disabled={addingRelay}
>
<span className="flex items-center gap-2">
<Plus size={16} />
{addingRelay ? "Adding…" : "Add"}
</span>
</Button>
</form>
<Button
variant="secondary"
size="md"
type="button"
onClick={handleImportNip65}
disabled={importingNip65}
>
<span className="flex items-center gap-2">
{importingNip65 ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
{importingNip65 ? "Importing…" : "Import from NIP-65"}
</span>
</Button>
</div>
{loadingRelays ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-surface-container-low rounded-xl p-5">
<div className="h-5 w-2/3 bg-surface-container-high rounded" />
</div>
))}
</div>
) : userRelays.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<Radio size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">
No relays configured. Add a relay manually or import from your NIP-65 profile.
</p>
</div>
) : (
<div className="space-y-3">
{userRelays.map((relay) => (
<div
key={relay.id}
className="bg-surface-container-low rounded-xl px-5 py-4 flex items-center justify-between gap-4"
>
<div className="flex-1 min-w-0">
<p className="font-mono text-sm text-on-surface truncate">
{relay.url}
</p>
<div className="flex gap-2 mt-1.5">
{relay.read && (
<span className="flex items-center gap-1 text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded-full">
<Eye size={12} />
Read
</span>
)}
{relay.write && (
<span className="flex items-center gap-1 text-xs font-semibold text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full">
<Pencil size={12} />
Write
</span>
)}
</div>
</div>
<button
onClick={() => handleRemoveRelay(relay.id)}
className="text-on-surface-variant/50 hover:text-error transition-colors p-2 rounded-lg hover:bg-error/10"
title="Remove relay"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</section>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
// Route-segment error boundary. Catches runtime errors thrown while rendering any
// page/segment (e.g. a request-time fetch or a bad build artifact) and shows a
// recoverable UI with a retry, instead of a bare 500.
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Route error boundary caught:", error);
}, [error]);
return (
<div className="min-h-screen flex flex-col items-center justify-center px-8">
<span className="text-7xl md:text-9xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-primary to-primary-container leading-none">
Oops
</span>
<h1 className="text-2xl md:text-3xl font-bold mt-6 mb-3">
Something went wrong
</h1>
<p className="text-on-surface-variant mb-10 text-center max-w-md">
This page hit an unexpected error. It&apos;s usually temporary try again
in a moment.
</p>
<div className="flex flex-wrap gap-3 justify-center">
<button
onClick={() => reset()}
className="bg-gradient-to-r from-primary to-primary-container text-on-primary px-8 py-3 rounded-lg font-bold hover:scale-105 transition-transform"
>
Try again
</button>
<Link
href="/"
className="border border-zinc-700 px-8 py-3 rounded-lg font-bold hover:bg-zinc-800/50 transition-colors"
>
Back to Home
</Link>
</div>
{error?.digest && (
<p className="text-on-surface-variant/50 text-xs mt-8 font-mono">
ref: {error.digest}
</p>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildEventsMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildEventsMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+40 -16
View File
@@ -2,25 +2,17 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, MapPin, Clock, Calendar, ExternalLink } from "lucide-react";
import { ArrowLeft, MapPin, Clock, Calendar, ExternalLink, Building2 } from "lucide-react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
function formatFullDate(dateStr: string) {
const d = new Date(dateStr);
return d.toLocaleString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
import { UpcomingEventsCarousel } from "@/components/public/UpcomingEventsCarousel";
import { formatMeetupCivilDate, formatMeetupCivilDateLong, getMeetupStartUtc } from "@/lib/meetupEventTime";
function DateBadge({ dateStr }: { dateStr: string }) {
const d = new Date(dateStr);
const month = d.toLocaleString("en-US", { month: "short" }).toUpperCase();
const day = String(d.getDate());
const civil = formatMeetupCivilDate(dateStr);
const month = civil?.monthShort ?? "—";
const day = civil?.day ?? "--";
return (
<div className="bg-zinc-800 rounded-xl px-4 py-3 text-center shrink-0 min-w-[60px]">
<span className="block text-[11px] font-bold uppercase text-primary tracking-wider leading-none mb-1">
@@ -61,7 +53,12 @@ export default function EventDetailClient({ id }: { id: string }) {
.finally(() => setLoading(false));
}, [id]);
const isPast = meetup ? new Date(meetup.date) < new Date() : false;
const isPast = meetup
? (() => {
const start = getMeetupStartUtc(meetup.date, meetup.time || "00:00");
return !Number.isNaN(start.getTime()) && start < new Date();
})()
: false;
return (
<>
@@ -115,10 +112,35 @@ export default function EventDetailClient({ id }: { id: string }) {
</div>
</div>
<div className="flex flex-wrap gap-4 mb-6 text-sm text-on-surface-variant">
{meetup.organizer?.slug ? (
<Link
href={`/events/organizer/${meetup.organizer.slug}`}
className="flex items-center gap-2 text-primary hover:underline underline-offset-4"
>
<Building2 size={15} className="text-primary/70 shrink-0" />
<span>
Organized by{" "}
<span className="font-semibold">
{meetup.organizer.name || "Belgian Bitcoin Embassy"}
</span>
</span>
</Link>
) : (
<div className="flex items-center gap-2">
<Building2 size={15} className="text-primary/70 shrink-0" />
<span>
Organized by{" "}
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-4 mb-10 text-sm text-on-surface-variant">
<div className="flex items-center gap-2">
<Calendar size={15} className="text-primary/70 shrink-0" />
{formatFullDate(meetup.date)}
{formatMeetupCivilDateLong(meetup.date)}
</div>
{meetup.time && (
<div className="flex items-center gap-2">
@@ -152,6 +174,8 @@ export default function EventDetailClient({ id }: { id: string }) {
Register for this event <ExternalLink size={16} />
</a>
)}
<UpcomingEventsCarousel excludeId={meetup.id} />
</>
)}
</div>
+20 -2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import EventDetailClient from "./EventDetailClient";
import { EventJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
import { getMeetupStartUtc, getMeetupEndUtc } from "@/lib/meetupEventTime";
async function fetchEvent(id: string) {
try {
@@ -26,9 +27,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
return { title: "Event Not Found" };
}
const orgLabel = event.organizer?.name || "Belgian Bitcoin Embassy";
const description =
event.description?.slice(0, 160) ||
`Bitcoin meetup: ${event.title}${event.location ? ` in ${event.location}` : ""}. Organized by the Belgian Bitcoin Embassy.`;
`Bitcoin meetup: ${event.title}${event.location ? ` in ${event.location}` : ""}. Organized by ${orgLabel}.`;
const ogImage = event.imageId
? `/media/${event.imageId}`
@@ -58,6 +60,15 @@ export default async function EventDetailPage({ params }: Props) {
const event = await fetchEvent(id);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
let startDate = event?.date;
let endDate: string | undefined;
if (event?.date) {
const start = getMeetupStartUtc(event.date, event.time || "00:00");
if (!Number.isNaN(start.getTime())) startDate = start.toISOString();
const end = getMeetupEndUtc(event.date, event.time || "");
if (end && !Number.isNaN(end.getTime())) endDate = end.toISOString();
}
return (
<>
{event && (
@@ -65,10 +76,17 @@ export default async function EventDetailPage({ params }: Props) {
<EventJsonLd
name={event.title}
description={event.description}
startDate={event.date}
startDate={startDate}
endDate={endDate}
location={event.location}
url={`${siteUrl}/events/${id}`}
imageUrl={event.imageId ? `${siteUrl}/media/${event.imageId}` : undefined}
organizerName={event.organizer?.name}
organizerUrl={
event.organizer?.slug
? `${siteUrl}/events/organizer/${event.organizer.slug}`
: undefined
}
/>
<BreadcrumbJsonLd
items={[
+12 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Events - Bitcoin Meetups in Belgium",
@@ -13,5 +14,15 @@ export const metadata: Metadata = {
};
export default function EventsLayout({ children }: { children: React.ReactNode }) {
return children;
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Events", href: "/events" },
]}
/>
{children}
</>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { MeetupCard } from "@/components/public/MeetupCard";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { ArrowLeft } from "lucide-react";
function CardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 animate-pulse">
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg w-[52px] h-[58px] shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-3 bg-zinc-800 rounded w-1/2" />
</div>
</div>
</div>
);
}
export default function OrganizerEventsClient({
slug,
organizerName,
}: {
slug: string;
organizerName: string;
}) {
const [meetups, setMeetups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
api
.getMeetups({ organizerSlug: slug })
.then((data: any) => {
const list = Array.isArray(data) ? data : [];
setMeetups(list);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [slug]);
const now = new Date();
const upcoming = meetups.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start >= now;
});
const past = meetups
.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start < now;
})
.reverse();
return (
<>
<Navbar />
<div className="min-h-screen">
<header className="pt-24 pb-12 px-8">
<div className="max-w-6xl mx-auto">
<Link
href="/events"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-6 text-sm font-medium"
>
<ArrowLeft size={16} />
All events
</Link>
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
Organizer
</p>
<h1 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">
{organizerName}
</h1>
<p className="text-on-surface-variant max-w-md leading-relaxed">
Upcoming and past events hosted by this organizer.
</p>
</div>
</header>
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
{error && (
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
Failed to load events: {error}
</div>
)}
<div>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-black flex items-center gap-3">
Upcoming
{!loading && upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
)}
</h2>
<AddToCalendarButton />
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => (
<CardSkeleton key={i} />
))}
</div>
) : upcoming.length === 0 ? (
<div className="border border-zinc-800/60 rounded-xl px-8 py-12 text-center">
<p className="text-on-surface-variant text-sm">
No upcoming events from this organizer.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{upcoming.map((m) => (
<MeetupCard key={m.id} meetup={m} />
))}
</div>
)}
</div>
{(loading || past.length > 0) && (
<div>
<h2 className="text-xl font-black mb-8 text-on-surface-variant/60">Past events</h2>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => (
<CardSkeleton key={i} />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{past.map((m) => (
<MeetupCard key={m.id} meetup={m} muted />
))}
</div>
)}
</div>
)}
</div>
</div>
<Footer />
</>
);
}
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import OrganizerEventsClient from "./OrganizerEventsClient";
import { apiUrl } from "@/lib/api-base";
async function fetchOrganizer(slug: string) {
try {
const res = await fetch(apiUrl(`/organizers/by-slug/${encodeURIComponent(slug)}`), {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json() as Promise<{ id: string; name: string; slug: string }>;
} catch {
return null;
}
}
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const org = await fetchOrganizer(slug);
if (!org) {
return { title: "Organizer not found" };
}
return {
title: `Events by ${org.name}`,
description: `Upcoming and past Bitcoin events organized by ${org.name} in Belgium.`,
alternates: { canonical: `/events/organizer/${slug}` },
};
}
export default async function OrganizerArchivePage({ params }: Props) {
const { slug } = await params;
const org = await fetchOrganizer(slug);
if (!org) {
notFound();
}
return <OrganizerEventsClient slug={slug} organizerName={org.name} />;
}
+35 -153
View File
@@ -1,128 +1,16 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { MeetupCard } from "@/components/public/MeetupCard";
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
import { fetchMeetupsLive, partitionMeetups } from "@/lib/meetupsData";
function formatMeetupDate(dateStr: string) {
const d = new Date(dateStr);
return {
month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
day: String(d.getDate()),
full: d.toLocaleString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
}),
};
}
// Render at request time from the live backend so crawlers (no JS) always get
// the real list, and the count stays in lock-step with /events.md and /llms.txt.
export const dynamic = "force-dynamic";
function MeetupCard({ meetup, muted = false }: { meetup: any; muted?: boolean }) {
const { month, day, full } = formatMeetupDate(meetup.date);
return (
<Link
href={`/events/${meetup.id}`}
className={`group flex flex-col bg-zinc-900 border rounded-xl p-6 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200 ${
muted
? "border-zinc-800/60 opacity-70 hover:opacity-100 hover:border-zinc-700"
: "border-zinc-800 hover:border-zinc-700"
}`}
>
<div className="flex items-start gap-4 mb-4">
<div className={`rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px] ${muted ? "bg-zinc-800/60" : "bg-zinc-800"}`}>
<span className={`block text-[10px] font-bold uppercase tracking-wider leading-none mb-0.5 ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="min-w-0">
<h3 className="font-bold text-base leading-snug group-hover:text-primary transition-colors">
{meetup.title}
</h3>
<p className="text-on-surface-variant/60 text-xs mt-1">{full}</p>
</div>
</div>
{meetup.description && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
{meetup.description}
</p>
)}
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
{meetup.location && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<MapPin size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.location}
</p>
)}
{meetup.time && (
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
<Clock size={12} className={`shrink-0 ${muted ? "text-on-surface-variant/40" : "text-primary/60"}`} />
{meetup.time}
</p>
)}
</div>
<span className={`flex items-center gap-1.5 text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all ${muted ? "text-on-surface-variant/50" : "text-primary"}`}>
View Details <ArrowRight size={12} />
</span>
</Link>
);
}
function CardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 animate-pulse">
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg w-[52px] h-[58px] shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-3 bg-zinc-800 rounded w-1/2" />
</div>
</div>
<div className="space-y-2 mb-4">
<div className="h-3 bg-zinc-800 rounded w-full" />
<div className="h-3 bg-zinc-800 rounded w-5/6" />
</div>
</div>
);
}
export default function EventsPage() {
const [meetups, setMeetups] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.getMeetups()
.then((data: any) => {
const list = Array.isArray(data) ? data : [];
setMeetups(list);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const now = new Date();
const upcoming = meetups.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start >= now;
});
const past = meetups
.filter((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start < now;
})
.reverse();
export default async function EventsPage() {
const meetups = await fetchMeetupsLive();
const { upcoming, past } = partitionMeetups(meetups);
return (
<>
@@ -142,28 +30,25 @@ export default function EventsPage() {
</div>
</header>
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
{error && (
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
Failed to load events: {error}
</div>
)}
<div
className="max-w-6xl mx-auto px-8 pb-24 space-y-20"
data-upcoming-count={upcoming.length}
data-past-count={past.length}
>
<div>
<h2 className="text-xl font-black mb-8 flex items-center gap-3">
Upcoming
{!loading && upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
)}
</h2>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-black flex items-center gap-3">
Upcoming
{upcoming.length > 0 && (
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
{upcoming.length}
</span>
)}
</h2>
<AddToCalendarButton />
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => <CardSkeleton key={i} />)}
</div>
) : upcoming.length === 0 ? (
{upcoming.length === 0 ? (
<div className="border border-zinc-800/60 rounded-xl px-8 py-12 text-center">
<p className="text-on-surface-variant text-sm">
No upcoming events scheduled. Check back soon.
@@ -171,26 +56,23 @@ export default function EventsPage() {
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{upcoming.map((m) => <MeetupCard key={m.id} meetup={m} />)}
{upcoming.map((m) => (
<MeetupCard key={m.id} meetup={m} />
))}
</div>
)}
</div>
{(loading || past.length > 0) && (
{past.length > 0 && (
<div>
<h2 className="text-xl font-black mb-8 text-on-surface-variant/60">
Past Events
</h2>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[0, 1, 2].map((i) => <CardSkeleton key={i} />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{past.map((m) => <MeetupCard key={m.id} meetup={m} muted />)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{past.map((m) => (
<MeetupCard key={m.id} meetup={m} muted />
))}
</div>
</div>
)}
</div>
+10
View File
@@ -0,0 +1,10 @@
import { buildFaqMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildFaqMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+31 -70
View File
@@ -1,12 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { FaqPageJsonLd } from "@/components/public/JsonLd";
import { FaqAccordion } from "@/components/public/FaqAccordion";
import { FaqPageJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
import { apiUrl } from "@/lib/api-base";
interface FaqItem {
id: string;
@@ -16,25 +12,37 @@ interface FaqItem {
showOnHomepage: boolean;
}
export default function FaqPage() {
const [items, setItems] = useState<FaqItem[]>([]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
// Render at request time from the live backend so the Q&A and FAQPage JSON-LD
// are always present in the HTML for crawlers, never a build-time-empty shell.
export const dynamic = "force-dynamic";
useEffect(() => {
api.getFaqsAll()
.then((data) => {
if (Array.isArray(data)) setItems(data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
async function fetchFaqs(): Promise<FaqItem[]> {
try {
const res = await fetch(apiUrl("/faqs?all=true"), { cache: "no-store" });
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export default async function FaqPage() {
const items = await fetchFaqs();
return (
<>
{items.length > 0 && (
<FaqPageJsonLd items={items.map((i) => ({ question: i.question, answer: i.answer }))} />
<FaqPageJsonLd
items={items.map((i) => ({ question: i.question, answer: i.answer }))}
/>
)}
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "FAQ", href: "/faq" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
@@ -43,57 +51,10 @@ export default function FaqPage() {
Everything you need to know about the Belgian Bitcoin Embassy.
</p>
{loading && (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-surface-container-low rounded-xl h-[72px] animate-pulse" />
))}
</div>
)}
{!loading && items.length === 0 && (
{items.length === 0 ? (
<p className="text-on-surface-variant">No FAQs available yet.</p>
)}
{!loading && items.length > 0 && (
<div className="space-y-4">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={item.id}
className="bg-surface-container-low rounded-xl overflow-hidden"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="w-full flex items-center justify-between p-6 text-left"
>
<span className="text-lg font-bold pr-4">{item.question}</span>
<ChevronDown
size={20}
className={cn(
"shrink-0 text-primary transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
) : (
<FaqAccordion items={items} />
)}
</div>
</div>
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect } from "react";
// Last-resort boundary: catches errors thrown in the root layout itself. It
// replaces the whole document, so it must render its own <html>/<body> and can't
// rely on the app's global CSS — hence inline styles.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Global error boundary caught:", error);
}, [error]);
return (
<html lang="en">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
padding: "2rem",
background: "#0a0a0a",
color: "#ededed",
fontFamily:
"ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "1.75rem", fontWeight: 800, margin: 0 }}>
Something went wrong
</h1>
<p style={{ color: "#a1a1aa", maxWidth: "28rem", margin: 0 }}>
The site hit an unexpected error. Please try again in a moment.
</p>
<button
onClick={() => reset()}
style={{
marginTop: "0.5rem",
padding: "0.75rem 2rem",
borderRadius: "0.5rem",
border: "none",
fontWeight: 700,
cursor: "pointer",
background: "#f7931a",
color: "#0a0a0a",
}}
>
Try again
</button>
{error?.digest && (
<p
style={{
color: "#71717a",
fontSize: "0.75rem",
fontFamily: "ui-monospace, monospace",
}}
>
ref: {error.digest}
</p>
)}
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { buildHomeMarkdown } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildHomeMarkdown();
return new Response(body, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+27 -16
View File
@@ -1,10 +1,22 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { ClientProviders } from "@/components/providers/ClientProviders";
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/public/JsonLd";
import { fetchPublicSettings, socialUrlsFromSettings } from "@/lib/seo";
import "./globals.css";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
const plausibleDomain = process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN?.trim();
const plausibleAnalyticsOrigin = process.env.NEXT_PUBLIC_PLAUSIBLE_ANALYTICS_ORIGIN?.trim().replace(
/\/$/,
"",
);
const plausibleScriptSrc =
plausibleDomain && plausibleAnalyticsOrigin
? `${plausibleAnalyticsOrigin}/js/script.js`
: null;
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
@@ -12,18 +24,7 @@ export const metadata: Metadata = {
template: "%s | Belgian Bitcoin Embassy",
},
description:
"Belgium's sovereign Bitcoin community. Monthly meetups in Antwerp, Bitcoin education, and curated Nostr content. No hype, just signal.",
keywords: [
"Bitcoin",
"Belgium",
"Antwerp",
"Bitcoin meetup",
"Bitcoin education",
"Nostr",
"Belgian Bitcoin Embassy",
"Bitcoin community Belgium",
"Bitcoin events Antwerp",
],
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
authors: [{ name: "Belgian Bitcoin Embassy" }],
creator: "Belgian Bitcoin Embassy",
publisher: "Belgian Bitcoin Embassy",
@@ -33,7 +34,7 @@ export const metadata: Metadata = {
siteName: "Belgian Bitcoin Embassy",
title: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
images: [
{
url: "/og-default.png",
@@ -47,7 +48,7 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "Belgian Bitcoin Embassy",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
images: ["/og-default.png"],
},
robots: {
@@ -79,11 +80,21 @@ export const viewport: Viewport = {
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await fetchPublicSettings();
const sameAs = socialUrlsFromSettings(settings);
return (
<html lang="en" dir="ltr" className="dark">
<body>
<OrganizationJsonLd />
{plausibleScriptSrc && plausibleDomain ? (
<Script
defer
src={plausibleScriptSrc}
data-domain={plausibleDomain}
strategy="afterInteractive"
/>
) : null}
<OrganizationJsonLd sameAs={sameAs} />
<WebSiteJsonLd />
<ClientProviders>{children}</ClientProviders>
</body>
+10
View File
@@ -0,0 +1,10 @@
import { buildLlmsTxt } from "@/lib/llms";
export const dynamic = "force-dynamic";
export async function GET() {
const body = await buildLlmsTxt();
return new Response(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
+6 -6
View File
@@ -38,11 +38,11 @@ export default function LoginPage() {
const [bunkerInput, setBunkerInput] = useState("");
useEffect(() => {
if (!loading && user) redirectByRole(user.role);
if (!loading && user) redirectFor(user);
}, [user, loading]);
function redirectByRole(role: string) {
if (role === "ADMIN" || role === "MODERATOR") {
function redirectFor(u: { isSuperAdmin?: boolean; permissions?: string[] }) {
if (u.isSuperAdmin || (u.permissions?.length ?? 0) > 0) {
router.push("/admin/overview");
} else {
router.push("/dashboard");
@@ -78,7 +78,7 @@ export default function LoginPage() {
setLoggingIn(true);
const loggedInUser = await loginWithConnectedSigner(signer);
await signer.close().catch(() => {});
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
if (controller.signal.aborted) return;
setError(err.message || "Connection failed");
@@ -104,7 +104,7 @@ export default function LoginPage() {
setLoggingIn(true);
try {
const loggedInUser = await login();
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
@@ -118,7 +118,7 @@ export default function LoginPage() {
setLoggingIn(true);
try {
const loggedInUser = await loginWithBunker(bunkerInput.trim());
redirectByRole(loggedInUser.role);
redirectFor(loggedInUser);
} catch (err: any) {
setError(err.message || "Connection failed");
} finally {
+61 -39
View File
@@ -180,50 +180,72 @@ function handleVideoStream(
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
// Media blobs are stored as ULIDs (Crockford base32, 26 chars). Reject anything
// else before joining into the storage path so `../` cannot escape the root.
const MEDIA_ID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
function resolveSafeMediaPath(root: string, id: string): string | null {
if (!MEDIA_ID_RE.test(id)) return null;
const resolvedRoot = path.resolve(root);
const filePath = path.resolve(resolvedRoot, id);
const rootPrefix = resolvedRoot.endsWith(path.sep)
? resolvedRoot
: resolvedRoot + path.sep;
if (filePath !== resolvedRoot && !filePath.startsWith(rootPrefix)) {
return null;
}
return filePath;
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> | { id: string } }
) {
const params = await Promise.resolve(context.params);
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const root = getMediaStorageRoot();
const filePath = path.join(root, id);
if (!fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const meta = readMeta(root, id);
if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const widthParam = searchParams.get('w');
if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
try {
const params = await Promise.resolve(context.params);
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
const root = getMediaStorageRoot();
const filePath = resolveSafeMediaPath(root, id);
if (!filePath || !fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length),
...CACHE_HEADERS,
},
});
const meta = readMeta(root, id);
if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const widthParam = searchParams.get('w');
if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
}
return await handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length),
...CACHE_HEADERS,
},
});
} catch (err) {
console.error('Media serve error:', err);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+17 -7
View File
@@ -3,7 +3,6 @@
import { useEffect, useState } from "react";
import { Navbar } from "@/components/public/Navbar";
import { HeroSection } from "@/components/public/HeroSection";
import { KnowledgeCards } from "@/components/public/KnowledgeCards";
import { AboutSection } from "@/components/public/AboutSection";
import { CommunityLinksSection } from "@/components/public/CommunityLinksSection";
import { MeetupsSection } from "@/components/public/MeetupsSection";
@@ -11,6 +10,7 @@ import { FAQSection } from "@/components/public/FAQSection";
import { FinalCTASection } from "@/components/public/FinalCTASection";
import { Footer } from "@/components/public/Footer";
import { api } from "@/lib/api";
import { formatMeetupCivilDate, getMeetupStartUtc } from "@/lib/meetupEventTime";
export default function HomePage() {
const [meetup, setMeetup] = useState<any>(null);
@@ -22,10 +22,18 @@ export default function HomePage() {
.then((data: any) => {
const all = Array.isArray(data) ? data : data?.meetups ?? [];
const now = new Date();
// Keep only PUBLISHED events with a future date, sorted closest-first
// Keep only PUBLISHED events with a future start (Brussels wall time → UTC), sorted closest-first
const upcoming = all
.filter((m: any) => m.status === "PUBLISHED" && m.date && new Date(m.date) > now)
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime());
.filter((m: any) => {
if (m.status !== "PUBLISHED" || !m.date) return false;
const start = getMeetupStartUtc(m.date, m.time || "00:00");
return !Number.isNaN(start.getTime()) && start > now;
})
.sort(
(a: any, b: any) =>
getMeetupStartUtc(a.date, a.time || "00:00").getTime() -
getMeetupStartUtc(b.date, b.time || "00:00").getTime()
);
setAllMeetups(upcoming);
if (upcoming.length > 0) setMeetup(upcoming[0]);
})
@@ -36,11 +44,14 @@ export default function HomePage() {
.catch(() => {});
}, []);
const featuredCivil = meetup ? formatMeetupCivilDate(meetup.date) : null;
const meetupProps = meetup
? {
id: meetup.id,
month: new Date(meetup.date).toLocaleString("en-US", { month: "short" }),
day: String(new Date(meetup.date).getDate()),
month: featuredCivil
? featuredCivil.monthShort.charAt(0) + featuredCivil.monthShort.slice(1).toLowerCase()
: "TBD",
day: featuredCivil?.day ?? "--",
title: meetup.title,
location: meetup.location,
time: meetup.time,
@@ -57,7 +68,6 @@ export default function HomePage() {
<section id="about">
<AboutSection />
</section>
<KnowledgeCards />
<CommunityLinksSection settings={settings} />
<section id="upcoming-meetups">
<MeetupsSection meetups={allMeetups} />
+7
View File
@@ -0,0 +1,7 @@
import { PRIVACY_MARKDOWN } from "@/lib/content/legalMarkdown";
export async function GET() {
return new Response(PRIVACY_MARKDOWN, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+78 -20
View File
@@ -2,14 +2,16 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Privacy Policy",
description:
"Privacy policy for the Belgian Bitcoin Embassy website. We collect minimal data, use no tracking cookies, and respect your sovereignty.",
"GDPR-oriented privacy policy for the Belgian Bitcoin Embassy. Learn what data we process, why we process it, and your rights.",
openGraph: {
title: "Privacy Policy - Belgian Bitcoin Embassy",
description: "How we handle your data. Minimal collection, no tracking, full transparency.",
description:
"How we process data for account access, moderation, and community features, with clear GDPR rights and transparency.",
},
alternates: { canonical: "/privacy" },
};
@@ -17,46 +19,102 @@ export const metadata: Metadata = {
export default function PrivacyPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Privacy Policy", href: "/privacy" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
<h1 className="text-4xl font-black mb-8">Privacy Policy</h1>
<h1 className="text-4xl font-black mb-3">Privacy Policy</h1>
<p className="text-sm text-on-surface-variant mb-8">Last updated: April 3, 2026</p>
<div className="space-y-8 text-on-surface-variant leading-relaxed">
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Overview</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Who We Are</h2>
<p>
The Belgian Bitcoin Embassy values your privacy. This website is designed
to collect as little personal data as possible. We do not use tracking
cookies, analytics services, or advertising networks.
Belgian Bitcoin Embassy is a community initiative focused on Bitcoin education
and meetups in Belgium. We aim to process the minimum data needed to run this
website safely and reliably.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Data We Collect</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">What Data We Process</h2>
<p>
If you log in using a Nostr extension, we store your public key to
identify your session. Public keys are, by nature, public information
on the Nostr network. We do not collect email addresses, names, or
any other personal identifiers.
If you log in with Nostr, we process your public key, role, and optional
username. We also process content needed to operate the site, such as posts,
submissions, media metadata, and moderation records. Some Nostr-related data
may be cached on our servers to improve performance.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Nostr Interactions</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Why We Process Data</h2>
<p>
Likes and comments are published to the Nostr network via your own
extension. These are peer-to-peer actions and are not stored on our
servers beyond local caching for display purposes.
We process data to provide core site features, maintain account sessions,
prevent abuse, moderate community interactions, and keep the service secure.
Our legal bases are contract (or steps requested by you before using features)
and legitimate interests (security, integrity, and service operation).
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Local Storage</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Cookies and Local Storage</h2>
<p>
We use browser local storage to persist your authentication session.
You can clear this at any time by logging out or clearing your
browser data.
We currently do not use third-party analytics or advertising cookies. We do use
browser local storage to keep your authentication session active. You can clear
this data at any time by logging out or clearing browser storage.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Recipients</h2>
<p>
When you interact through Nostr, your actions are published on the Nostr network,
which is public by design. We may also use infrastructure providers to host and
secure the website.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Retention</h2>
<p>
We keep account and operational data only as long as needed for service operation,
security, and moderation. Technical logs may be retained for a limited period.
You can remove local browser data at any time.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Your GDPR Rights</h2>
<p>
Depending on applicable law, you may have rights to access, rectify, erase, restrict,
object to, or request portability of your personal data. You also have the right to
lodge a complaint with the Belgian Data Protection Authority.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">International Transfers</h2>
<p>
If technical providers process data outside the EEA, we aim to rely on appropriate
safeguards as required under GDPR.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Children</h2>
<p>This website is not directed at children under the age of 16.</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Policy Updates</h2>
<p>
We may update this Privacy Policy from time to time. Material changes are reflected
by updating the date at the top of this page.
</p>
</section>
+7
View File
@@ -0,0 +1,7 @@
import { TERMS_MARKDOWN } from "@/lib/content/legalMarkdown";
export async function GET() {
return new Response(TERMS_MARKDOWN, {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
+87 -20
View File
@@ -2,14 +2,16 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
export const metadata: Metadata = {
title: "Terms of Use",
description:
"Terms of use for the Belgian Bitcoin Embassy website. Community-driven, non-commercial Bitcoin education platform in Belgium.",
"Terms of use for the Belgian Bitcoin Embassy website, including education-only scope, risk warnings, and user responsibilities.",
openGraph: {
title: "Terms of Use - Belgian Bitcoin Embassy",
description: "Terms governing the use of the Belgian Bitcoin Embassy platform.",
description:
"Terms governing access, risk disclosures, no-investment-advice scope, and liability limits for the Belgian Bitcoin Embassy platform.",
},
alternates: { canonical: "/terms" },
};
@@ -17,37 +19,65 @@ export const metadata: Metadata = {
export default function TermsPage() {
return (
<>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Terms of Use", href: "/terms" },
]}
/>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
<h1 className="text-4xl font-black mb-8">Terms of Use</h1>
<h1 className="text-4xl font-black mb-3">Terms of Use</h1>
<p className="text-sm text-on-surface-variant mb-8">Last updated: April 3, 2026</p>
<div className="space-y-8 text-on-surface-variant leading-relaxed">
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">About This Site</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Acceptance and Changes</h2>
<p>
The Belgian Bitcoin Embassy website is a community-driven, non-commercial
platform focused on Bitcoin education and meetups in Belgium. By using
this site, you agree to these terms.
By accessing or using this website, you agree to these Terms of Use. We may
update these terms from time to time, and continued use after updates means you
accept the revised terms.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Content</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Nature of the Service</h2>
<p>
Blog content on this site is curated from the Nostr network. The
Belgian Bitcoin Embassy does not claim ownership of third-party
content and provides it for educational purposes only. Content
moderation is applied locally and does not affect the Nostr network.
This website provides general Bitcoin education and community information.
Nothing on this website is financial, investment, legal, or tax advice.
We do not make recommendations to buy, sell, or hold Bitcoin or any other
crypto-asset. Content is general in nature and not tailored to your personal
circumstances.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">No Financial Advice</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Crypto Risk Warning</h2>
<p>
Nothing on this website constitutes financial advice. Bitcoin is a
volatile asset. Always do your own research and consult qualified
professionals before making financial decisions.
Crypto-assets are highly volatile and you can lose all of your money.
Crypto-assets are not regulated in the same way as traditional financial
products. Regulatory rules may change, and availability may differ by
jurisdiction. Always do your own research and consult a qualified professional
before making financial decisions.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">MiCA and Regulatory Position</h2>
<p>
Belgian Bitcoin Embassy presents this website as an educational platform and not
as a crypto-asset service provider. If the nature of our activities changes, we
may update these terms and related legal pages.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Content and Third Parties</h2>
<p>
Some content is curated from the Nostr network. We do not claim ownership of
third-party content. Local moderation may hide or limit content on this site,
but does not change content on the Nostr network itself.
</p>
</section>
@@ -61,11 +91,48 @@ export default function TermsPage() {
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Liability</h2>
<h2 className="text-xl font-bold text-on-surface mb-4">Paid and Commercial Features</h2>
<p>
The Belgian Bitcoin Embassy is a community initiative, not a legal
entity. We provide this platform as-is with no warranties. Use at
your own discretion.
Certain features may involve Lightning payments, such as paid public board
messages. Any such feature is optional and does not change the educational
nature of the site.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Affiliate and Sponsorship Transparency</h2>
<p>
As of the last updated date above, we do not earn referral fees from links on
this website. If sponsored or affiliate content is added in the future, it will
be clearly disclosed.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Disclaimer and Liability</h2>
<p>
This platform is provided on an &quot;as is&quot; and &quot;as available&quot; basis without
warranties of any kind. To the maximum extent permitted by law, Belgian Bitcoin
Embassy is not liable for losses or damages resulting from your use of this site
or reliance on its content.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Governing Law</h2>
<p>
These terms are governed by Belgian law, without prejudice to mandatory consumer
protections that apply in your jurisdiction.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Contact</h2>
<p>
For terms-related questions, contact us through our{" "}
<Link href="/#community" className="text-primary hover:underline">
community channels
</Link>.
</p>
</section>
</div>
+41 -23
View File
@@ -19,34 +19,49 @@ import {
Inbox,
ImageIcon,
HelpCircle,
MessageSquare,
Building2,
KeyRound,
Key,
} from "lucide-react";
const navItems = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard, adminOnly: false },
{ href: "/admin/events", label: "Events", icon: Calendar, adminOnly: false },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, adminOnly: false },
{ href: "/admin/blog", label: "Blog", icon: FileText, adminOnly: false },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, adminOnly: false },
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, adminOnly: false },
{ href: "/admin/moderation", label: "Moderation", icon: Shield, adminOnly: false },
{ href: "/admin/categories", label: "Categories", icon: Tag, adminOnly: false },
{ href: "/admin/users", label: "Users", icon: Users, adminOnly: true },
{ href: "/admin/relays", label: "Relays", icon: Radio, adminOnly: true },
{ href: "/admin/settings", label: "Settings", icon: Settings, adminOnly: true },
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, adminOnly: true },
// Each item is shown when the user holds any of its permissions. Items with no
// permissions are always visible. SuperAdmin sees everything via can().
const navItems: {
href: string;
label: string;
icon: typeof LayoutDashboard;
permissions?: string[];
}[] = [
{ href: "/admin/overview", label: "Overview", icon: LayoutDashboard },
{ href: "/admin/events", label: "Events", icon: Calendar, permissions: ["events.create", "events.edit", "events.delete"] },
{ href: "/admin/organizers", label: "Organizers", icon: Building2, permissions: ["organizers.manage"] },
{ href: "/admin/gallery", label: "Gallery", icon: ImageIcon, permissions: ["gallery.upload", "gallery.delete"] },
{ href: "/admin/blog", label: "Blog", icon: FileText, permissions: ["blog.draft", "blog.publish", "blog.delete"] },
{ href: "/admin/faq", label: "FAQ", icon: HelpCircle, permissions: ["faq.manage"] },
{ href: "/admin/submissions", label: "Submissions", icon: Inbox, permissions: ["submissions.review"] },
{ href: "/admin/messages", label: "Board", icon: MessageSquare, permissions: ["board.manage"] },
{ href: "/admin/moderation", label: "Moderation", icon: Shield, permissions: ["moderation.act"] },
{ href: "/admin/categories", label: "Categories", icon: Tag, permissions: ["categories.manage"] },
{ href: "/admin/users", label: "Users", icon: Users, permissions: ["users.assign_role", "nip05.assign"] },
{ href: "/admin/roles", label: "Roles", icon: KeyRound, permissions: ["roles.edit_permissions"] },
{ href: "/admin/api-keys", label: "API Keys", icon: Key, permissions: ["api_keys.manage"] },
{ href: "/admin/relays", label: "Relays", icon: Radio, permissions: ["relays.manage"] },
{ href: "/admin/settings", label: "Settings", icon: Settings, permissions: ["settings.edit"] },
{ href: "/admin/nostr", label: "Nostr Tools", icon: Wrench, permissions: ["nostr_tools.use"] },
];
export function AdminSidebar() {
const pathname = usePathname();
const { user, logout, isAdmin } = useAuth();
const { user, logout, can } = useAuth();
const shortPubkey = user?.pubkey
? `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`
: "";
return (
<aside className="w-64 bg-surface-container-lowest min-h-screen p-6 flex flex-col shrink-0">
<div className="mb-8">
<aside className="w-64 bg-surface-container-lowest h-screen sticky top-0 p-6 flex flex-col shrink-0">
<div className="mb-8 shrink-0">
<Link href="/" className="text-primary-container font-bold text-xl">
BBE Admin
</Link>
@@ -60,23 +75,26 @@ export function AdminSidebar() {
</div>
) : (
<>
<div className="mb-6">
<div className="mb-6 shrink-0">
<p className="text-on-surface/70 text-sm font-mono truncate">{shortPubkey}</p>
<span
className={cn(
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold",
user.role === "ADMIN"
"inline-block mt-1 rounded-full px-3 py-1 text-xs font-bold capitalize",
user.isSuperAdmin || user.role === "admin"
? "bg-primary-container/20 text-primary"
: "bg-secondary-container text-on-secondary-container"
)}
>
{user.role}
{user.isSuperAdmin ? "SuperAdmin" : user.role}
</span>
</div>
<nav className="flex-1 space-y-1">
<nav className="flex-1 overflow-y-auto min-h-0 space-y-1">
{navItems
.filter((item) => !item.adminOnly || isAdmin)
.filter(
(item) =>
!item.permissions || item.permissions.some((p) => can(p))
)
.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
@@ -98,7 +116,7 @@ export function AdminSidebar() {
})}
</nav>
<div className="mt-auto space-y-2 pt-6">
<div className="shrink-0 space-y-2 pt-6">
<button
onClick={logout}
className="flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-on-surface/70 hover:text-on-surface hover:bg-surface-container w-full"
+62
View File
@@ -0,0 +1,62 @@
"use client";
import Image from "next/image";
import { useNostrProfile } from "@/hooks/useNostrProfile";
import type { NostrProfile } from "@/lib/nostr";
function computeInitials(profile: NostrProfile | null, fallback?: string): string {
const name = profile?.name || profile?.displayName;
if (name?.trim()) return name.trim().slice(0, 2).toUpperCase();
const fb = fallback?.trim();
if (fb) {
// For npub-style fallbacks skip the "npub1" prefix for nicer initials.
if (fb.startsWith("npub1") && fb.length >= 8) return fb.slice(5, 7).toUpperCase();
return fb.slice(0, 2).toUpperCase();
}
return "?";
}
export interface NostrAvatarProps {
pubkey: string | null | undefined;
/** Rendered size in pixels (square). */
size?: number;
/** Text used to derive initials when no Nostr name/picture is available. */
fallbackText?: string;
className?: string;
}
// Self-contained avatar: fetches the user's Nostr metadata from relays and
// renders their profile picture, falling back to initials.
export function NostrAvatar({
pubkey,
size = 56,
fallbackText,
className = "",
}: NostrAvatarProps) {
const { profile, loading } = useNostrProfile(pubkey);
const displayName = profile?.name || profile?.displayName;
return (
<div
className={`shrink-0 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface ${className}`}
style={{ width: size, height: size }}
>
{loading ? (
<span className="text-on-surface/40 text-xs"></span>
) : profile?.picture ? (
<Image
src={profile.picture}
alt={displayName ? `Avatar: ${displayName}` : "Nostr profile picture"}
width={size}
height={size}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<span className="font-semibold text-sm" aria-hidden>
{computeInitials(profile, fallbackText)}
</span>
)}
</div>
);
}
@@ -0,0 +1,126 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { CalendarPlus, Copy, Check, Download, ExternalLink, X } from "lucide-react";
const siteUrl =
typeof window !== "undefined"
? window.location.origin
: process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
function Dialog({ onClose }: { onClose: () => void }) {
const icsUrl = `${siteUrl}/calendar.ics`;
const webcalUrl = icsUrl.replace(/^https?:\/\//, "webcal://");
const [copied, setCopied] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const handleCopy = async () => {
await navigator.clipboard.writeText(icsUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
className="relative bg-zinc-900 border border-zinc-800 rounded-2xl w-full max-w-md p-6 shadow-2xl"
>
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-on-surface-variant/50 hover:text-on-surface transition-colors"
aria-label="Close"
>
<X size={18} />
</button>
<div className="flex items-center gap-2.5 mb-4">
<div className="bg-primary/10 text-primary rounded-lg p-2">
<CalendarPlus size={20} />
</div>
<h2 className="text-lg font-bold">Add to Calendar</h2>
</div>
<p className="text-sm text-on-surface-variant leading-relaxed mb-6">
Subscribe to this feed to get all public Belgian Bitcoin Embassy
meetups in your calendar. New events are added automatically.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-semibold text-on-surface-variant/60 mb-1.5">
Calendar URL
</label>
<div className="flex gap-2">
<input
readOnly
value={icsUrl}
className="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-xs text-on-surface select-all focus:outline-none focus:border-primary/50"
onFocus={(e) => e.currentTarget.select()}
/>
<button
type="button"
onClick={handleCopy}
className="shrink-0 flex items-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2 text-xs font-medium transition-all"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<a
href={webcalUrl}
className="flex items-center justify-center gap-1.5 bg-primary text-on-primary rounded-lg px-3 py-2.5 text-xs font-semibold hover:brightness-110 transition-all"
>
<ExternalLink size={14} />
Open in Calendar
</a>
<a
href="/calendar.ics"
download="bbe-events.ics"
className="flex items-center justify-center gap-1.5 bg-zinc-800 border border-zinc-700 hover:border-primary/50 text-on-surface-variant hover:text-primary rounded-lg px-3 py-2.5 text-xs font-semibold transition-all"
>
<Download size={14} />
Download .ics
</a>
</div>
</div>
</div>
</div>
);
}
export function AddToCalendarButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title="Subscribe to get all future meetups automatically"
className={
className ??
"flex items-center gap-1.5 text-xs text-on-surface-variant/60 hover:text-primary border border-zinc-700 hover:border-primary/50 rounded-lg px-3 py-1.5 transition-all"
}
>
<CalendarPlus size={14} />
Add to Calendar
</button>
{open && <Dialog onClose={close} />}
</>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
export interface BlogPost {
id: string;
slug: string;
title: string;
excerpt?: string;
author?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
featured?: boolean;
}
export interface BlogCategory {
id: string;
name: string;
slug: string;
}
interface BlogIndexProps {
initialPosts: BlogPost[];
initialTotal: number;
categories: BlogCategory[];
limit: number;
}
function PostCardSkeleton() {
return (
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
<div className="p-6 space-y-4">
<div className="flex gap-2">
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
</div>
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
<div className="space-y-2">
<div className="h-4 w-full bg-surface-container-high rounded" />
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
</div>
<div className="flex justify-between items-center pt-4">
<div className="h-4 w-32 bg-surface-container-high rounded" />
<div className="h-4 w-24 bg-surface-container-high rounded" />
</div>
</div>
</div>
);
}
export function BlogIndex({
initialPosts,
initialTotal,
categories,
limit,
}: BlogIndexProps) {
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
const [total, setTotal] = useState(initialTotal);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isFirst = useRef(true);
useEffect(() => {
// The initial ("all", page 1) data is already rendered from the server.
if (isFirst.current) {
isFirst.current = false;
return;
}
let cancelled = false;
setLoading(true);
setError(null);
api
.getPosts({
category: activeCategory === "all" ? undefined : activeCategory,
page,
limit,
})
.then(({ posts: data, total: t }) => {
if (cancelled) return;
setPosts(data);
setTotal(t);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [activeCategory, page, limit]);
const totalPages = Math.ceil(total / limit);
const featured = posts.find((p) => p.featured);
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
return (
<>
<div className="max-w-7xl mx-auto px-8 mb-12">
<div className="flex flex-wrap gap-3">
<button
onClick={() => {
setActiveCategory("all");
setPage(1);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === "all"
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => {
setActiveCategory(cat.slug);
setPage(1);
}}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeCategory === cat.slug
? "bg-primary text-on-primary"
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
}`}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="max-w-7xl mx-auto px-8 pb-24">
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
Failed to load posts: {error}
</div>
)}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
) : posts.length === 0 ? (
<div className="text-center py-24">
<p className="text-2xl font-bold text-on-surface-variant mb-2">
No posts yet
</p>
<p className="text-on-surface-variant/60">
Check back soon for curated Bitcoin content.
</p>
</div>
) : (
<>
{featured && page === 1 && (
<Link
href={`/blog/${featured.slug}`}
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
>
<div className="p-8 md:p-12">
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
Featured
</span>
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
{featured.title}
</h2>
{featured.excerpt && (
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
{featured.excerpt}
</p>
)}
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
{featured.author && <span>{featured.author}</span>}
{featured.publishedAt && (
<span>{formatDate(featured.publishedAt)}</span>
)}
</div>
</div>
</Link>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{regularPosts.map((post) => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
>
{post.categories && post.categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((pc) => (
<span
key={pc.category.id}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{pc.category.name}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
{post.excerpt && (
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
)}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
{post.author && <span>{post.author}</span>}
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
{(post.publishedAt || post.createdAt) && (
<span>{formatDate(post.publishedAt || post.createdAt!)}</span>
)}
</div>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
Read <ArrowRight size={12} />
</span>
</div>
</Link>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 mt-16">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
<ArrowLeft size={16} /> Previous
</button>
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
>
Next <ChevronRight size={16} />
</button>
</div>
)}
</>
)}
</div>
</>
);
}
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Send, Zap, ExternalLink } from "lucide-react";
import { api } from "@/lib/api";
const CHANNELS = [
{
key: "telegram_link" as const,
title: "Telegram",
description:
"Join our Telegram group for quick questions and community chat.",
Icon: Send,
},
{
key: "nostr_link" as const,
title: "Nostr",
description: "Follow us on Nostr for censorship-resistant communication.",
Icon: Zap,
},
{
key: "x_link" as const,
title: "X (Twitter)",
description: "Follow us on X for announcements and updates.",
Icon: ExternalLink,
},
];
export function ContactChannelGrid() {
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
api
.getPublicSettings()
.then((data) => setSettings(data))
.catch(() => {});
}, []);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{CHANNELS.map(({ key, title, description, Icon }) => {
const href = settings[key] || "#";
const isExternal = href.startsWith("http");
return (
<a
key={key}
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="bg-surface-container-low p-8 rounded-xl hover:bg-surface-container transition-colors group"
>
<Icon size={28} className="text-primary mb-4" />
<h2 className="text-xl font-bold mb-2">{title}</h2>
<p className="text-on-surface-variant text-sm">{description}</p>
</a>
);
})}
<div className="bg-surface-container-low p-8 rounded-xl">
<h2 className="text-xl font-bold mb-2">Meetups</h2>
<p className="text-on-surface-variant text-sm mb-4">
The best way to connect is in person. Come to our monthly meetup in
Brussels.
</p>
<Link
href="/#meetup"
className="text-primary font-bold text-sm hover:underline"
>
See next meetup
</Link>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FaqAccordionItem {
id: string;
question: string;
answer: string;
}
/**
* Interactive accordion. Items are passed in from the server so the full
* question + answer text is present in the initial HTML (indexable), while the
* open/close behaviour is hydrated on the client.
*/
export function FaqAccordion({ items }: { items: FaqAccordionItem[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<div className="space-y-4">
{items.map((item, i) => {
const isOpen = openIndex === i;
return (
<div
key={item.id}
className="bg-surface-container-low rounded-xl overflow-hidden"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
aria-expanded={isOpen}
className="w-full flex items-center justify-between p-6 text-left"
>
<span className="text-lg font-bold pr-4">{item.question}</span>
<ChevronDown
size={20}
className={cn(
"shrink-0 text-primary transition-transform duration-200",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"grid transition-all duration-200",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
{item.answer}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More