Compare commits
10
Commits
7acff1ae38
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ede8817583 | ||
|
|
495289232b | ||
|
|
2ef68222bf | ||
|
|
6023991f5c | ||
|
|
99380ef6aa | ||
|
|
a6a2b113ee | ||
|
|
70e3e0633d | ||
|
|
3bdd01dedf | ||
|
|
78271ea110 | ||
|
|
586b572f73 |
+29
-3
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+34
-1
@@ -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
@@ -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");
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
Executable
+18
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 } });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Slug for the default organizer row (seed + migration). */
|
||||
export const DEFAULT_ORGANIZER_SLUG = 'belgian-bitcoin-embassy';
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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'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))}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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="What’s 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
@@ -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'd like published on the
|
||||
blog.
|
||||
Submit a Nostr longform post for moderator review. Paste the
|
||||
note ID or naddr of the article you'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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 "as is" and "as available" 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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user