first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-04-01 02:46:53 +00:00
commit 76210db03d
126 changed files with 20208 additions and 0 deletions

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# Admin pubkeys (comma-separated hex pubkeys)
ADMIN_PUBKEYS=npub1examplepubkey1,npub1examplepubkey2
# Nostr relays (comma-separated)
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
# Database
DATABASE_URL="file:./dev.db"
# JWT
JWT_SECRET=change-me-to-a-random-secret-in-production
# Backend
BACKEND_PORT=4000
FRONTEND_URL=http://localhost:3000
# Media storage
MEDIA_STORAGE_PATH=./storage/media
# Frontend (public)
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

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Dependencies
node_modules/
# Next.js
frontend/.next/
frontend/out/
# Backend build
backend/dist/
# Environment (keep .env.example tracked)
.env
.env.*
!.env.example
**/.env.local
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs & debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# TypeScript
*.tsbuildinfo
# Test / coverage
coverage/
# Local SQLite databases
*.db
# Misc
.turbo
deploy/

106
README.md Normal file
View File

@@ -0,0 +1,106 @@
# Belgian Bitcoin Embassy
A Nostr-powered community website for Belgium's monthly Bitcoin meetup.
## Tech Stack
- **Frontend**: Next.js 14 (App Router), TypeScript, Tailwind CSS, Shadcn/ui
- **Backend**: Express.js, TypeScript, Prisma ORM
- **Database**: SQLite (default), PostgreSQL supported
- **Auth**: Nostr login (NIP-07), JWT sessions
## Quick Start
### 1. Clone and install
```bash
# Install backend dependencies
cd backend
npm install
# Install frontend dependencies
cd ../frontend
npm install
```
### 2. Configure environment
```bash
# From project root
cp .env.example backend/.env
cp .env.example frontend/.env.local
```
Edit `backend/.env` with your admin pubkeys and a secure JWT secret.
### 3. Set up database
```bash
cd backend
npx prisma generate
npx prisma db push
npx prisma db seed
```
### 4. Run development servers
```bash
# Terminal 1: Backend
cd backend
npm run dev
# Terminal 2: Frontend
cd frontend
npm run dev
```
- Frontend: http://localhost:3000
- Backend API: http://localhost:4000/api
## Project Structure
```
/frontend Next.js application
/app App Router pages
/components React components
/lib Utilities and API client
/hooks Custom React hooks
/backend Express API server
/src Source code
/api Route handlers
/services Business logic
/middleware Auth middleware
/prisma Database schema and migrations
/context Design specs (reference only)
```
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | /api/auth/challenge | Get auth challenge |
| POST | /api/auth/verify | Verify Nostr signature |
| GET | /api/posts | List blog posts |
| GET | /api/posts/:slug | Get post by slug |
| POST | /api/posts/import | Import Nostr post |
| PATCH | /api/posts/:id | Update post |
| GET | /api/meetups | List meetups |
| POST | /api/meetups | Create meetup |
| PATCH | /api/meetups/:id | Update meetup |
| POST | /api/moderation/hide | Hide content |
| POST | /api/moderation/block | Block pubkey |
| GET | /api/users | List users |
| POST | /api/users/promote | Promote user |
| GET | /api/categories | List categories |
| POST | /api/categories | Create category |
## Roles
- **Admin**: Full access. Defined by pubkeys in `.env`
- **Moderator**: Content moderation. Assigned by admins via dashboard.
## License
MIT

View File

@@ -0,0 +1,99 @@
admin
administrator
root
superuser
support
help
helpdesk
contact
info
noreply
no-reply
postmaster
webmaster
hostmaster
abuse
security
privacy
legal
press
media
marketing
nostr
bitcoin
btc
lightning
lnbc
embassy
belgianbitcoinembassy
bbe
api
system
daemon
service
server
www
mail
email
ftp
smtp
imap
pop
pop3
mx
ns
dns
cdn
static
assets
img
images
video
videos
files
uploads
download
downloads
backup
dev
staging
test
testing
demo
example
sample
null
undefined
true
false
me
you
we
they
user
users
account
accounts
profile
profiles
login
logout
signin
signup
register
password
reset
verify
auth
oauth
callback
redirect
feed
rss
atom
sitemap
robots
favicon
wellknown
_
__

2441
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
backend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "bbe-backend",
"version": "1.0.0",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:push": "prisma db push",
"db:seed": "prisma db seed",
"db:studio": "prisma studio"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.1",
"multer": "^2.1.1",
"nostr-tools": "^2.10.0",
"slugify": "^1.6.8",
"ulid": "^3.0.2",
"uuid": "^11.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/helmet": "^0.0.48",
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.10",
"@types/multer": "^2.1.0",
"@types/uuid": "^10.0.0",
"prisma": "^6.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,174 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'USER',
"displayName" TEXT,
"username" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "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 'UPCOMING',
"featured" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Media" (
"id" TEXT NOT NULL PRIMARY KEY,
"slug" TEXT NOT NULL,
"type" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"originalFilename" TEXT NOT NULL,
"uploadedBy" TEXT NOT NULL,
"title" TEXT,
"description" TEXT,
"altText" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Post" (
"id" TEXT NOT NULL PRIMARY KEY,
"nostrEventId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"content" TEXT NOT NULL,
"excerpt" TEXT,
"authorPubkey" TEXT NOT NULL,
"authorName" TEXT,
"featured" BOOLEAN NOT NULL DEFAULT false,
"visible" BOOLEAN NOT NULL DEFAULT true,
"publishedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Category" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "PostCategory" (
"postId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
PRIMARY KEY ("postId", "categoryId"),
CONSTRAINT "PostCategory_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PostCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "HiddenContent" (
"id" TEXT NOT NULL PRIMARY KEY,
"nostrEventId" TEXT NOT NULL,
"reason" TEXT,
"hiddenBy" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "BlockedPubkey" (
"id" TEXT NOT NULL PRIMARY KEY,
"pubkey" TEXT NOT NULL,
"reason" TEXT,
"blockedBy" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Relay" (
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Setting" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "NostrEventCache" (
"id" TEXT NOT NULL PRIMARY KEY,
"eventId" TEXT NOT NULL,
"kind" INTEGER NOT NULL,
"pubkey" TEXT NOT NULL,
"content" TEXT NOT NULL,
"tags" TEXT NOT NULL,
"createdAt" INTEGER NOT NULL,
"cachedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "Submission" (
"id" TEXT NOT NULL PRIMARY KEY,
"eventId" TEXT,
"naddr" TEXT,
"title" TEXT NOT NULL,
"authorPubkey" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"reviewedBy" TEXT,
"reviewNote" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Faq" (
"id" TEXT NOT NULL PRIMARY KEY,
"question" TEXT NOT NULL,
"answer" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"showOnHomepage" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Post_nostrEventId_key" ON "Post"("nostrEventId");
-- CreateIndex
CREATE UNIQUE INDEX "Post_slug_key" ON "Post"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Relay_url_key" ON "Relay"("url");
-- CreateIndex
CREATE UNIQUE INDEX "Setting_key_key" ON "Setting"("key");
-- CreateIndex
CREATE UNIQUE INDEX "NostrEventCache_eventId_key" ON "NostrEventCache"("eventId");

View File

@@ -0,0 +1,23 @@
-- 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 'UPCOMING',
"featured" BOOLEAN NOT NULL DEFAULT false,
"visibility" TEXT NOT NULL DEFAULT 'PUBLIC',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt") SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt" FROM "Meetup";
DROP TABLE "Meetup";
ALTER TABLE "new_Meetup" RENAME TO "Meetup";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,23 @@
-- 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',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Meetup" ("createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt", "visibility") SELECT "createdAt", "date", "description", "featured", "id", "imageId", "link", "location", "status", "time", "title", "updatedAt", "visibility" FROM "Meetup";
DROP TABLE "Meetup";
ALTER TABLE "new_Meetup" RENAME TO "Meetup";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

@@ -0,0 +1,149 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
pubkey String @unique
role String @default("USER") // USER, MODERATOR, ADMIN
displayName String?
username String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Meetup {
id String @id @default(uuid())
title String
description String
date String
time String
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
}
model Media {
id String @id // ULID, not auto-generated
slug String
type String // "image" | "video"
mimeType String
size Int
originalFilename String
uploadedBy String
title String?
description String?
altText String?
createdAt DateTime @default(now())
}
model Post {
id String @id @default(uuid())
nostrEventId String @unique
title String
slug String @unique
content String
excerpt String?
authorPubkey String
authorName String?
featured Boolean @default(false)
visible Boolean @default(true)
publishedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories PostCategory[]
}
model Category {
id String @id @default(uuid())
name String
slug String @unique
sortOrder Int @default(0)
createdAt DateTime @default(now())
posts PostCategory[]
}
model PostCategory {
postId String
categoryId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
}
model HiddenContent {
id String @id @default(uuid())
nostrEventId String
reason String?
hiddenBy String
createdAt DateTime @default(now())
}
model BlockedPubkey {
id String @id @default(uuid())
pubkey String
reason String?
blockedBy String
createdAt DateTime @default(now())
}
model Relay {
id String @id @default(uuid())
url String @unique
priority Int @default(0)
active Boolean @default(true)
createdAt DateTime @default(now())
}
model Setting {
id String @id @default(uuid())
key String @unique
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NostrEventCache {
id String @id @default(uuid())
eventId String @unique
kind Int
pubkey String
content String
tags String // JSON string
createdAt Int // event timestamp
cachedAt DateTime @default(now())
}
model Submission {
id String @id @default(uuid())
eventId String?
naddr String?
title String
authorPubkey String
status String @default("PENDING") // PENDING, APPROVED, REJECTED
reviewedBy String?
reviewNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Faq {
id String @id @default(uuid())
question String
answer String
order Int @default(0)
showOnHomepage Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

85
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,85 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const relays = [
{ url: 'wss://relay.damus.io', priority: 1 },
{ url: 'wss://nos.lol', priority: 2 },
{ url: 'wss://relay.nostr.band', priority: 3 },
];
for (const relay of relays) {
await prisma.relay.upsert({
where: { url: relay.url },
update: {},
create: relay,
});
}
const settings = [
{ key: 'site_title', value: 'Belgian Bitcoin Embassy' },
{ key: 'site_tagline', value: 'Your gateway to Bitcoin in Belgium' },
{ key: 'telegram_link', value: 'https://t.me/belgianbitcoinembassy' },
{ key: 'nostr_link', value: '' },
{ key: 'x_link', value: '' },
{ key: 'youtube_link', value: '' },
{ key: 'discord_link', value: '' },
{ key: 'linkedin_link', value: '' },
];
for (const setting of settings) {
await prisma.setting.upsert({
where: { key: setting.key },
update: {},
create: setting,
});
}
const categories = [
{ name: 'Bitcoin', slug: 'bitcoin', sortOrder: 1 },
{ name: 'Lightning', slug: 'lightning', sortOrder: 2 },
{ name: 'Privacy', slug: 'privacy', sortOrder: 3 },
{ name: 'Education', slug: 'education', sortOrder: 4 },
{ name: 'Community', slug: 'community', sortOrder: 5 },
];
for (const category of categories) {
await prisma.category.upsert({
where: { slug: category.slug },
update: {},
create: category,
});
}
const existingMeetup = await prisma.meetup.findFirst({
where: { title: 'Monthly Bitcoin Meetup' },
});
if (!existingMeetup) {
await prisma.meetup.create({
data: {
title: 'Monthly Bitcoin Meetup',
description:
'Join us for our monthly Bitcoin meetup! We discuss the latest developments, share knowledge, and connect with fellow Bitcoiners in Belgium.',
date: '2025-02-15',
time: '19:00',
location: 'Brussels, Belgium',
link: 'https://meetup.com/example',
status: 'UPCOMING',
featured: true,
},
});
}
console.log('Seed completed successfully.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

53
backend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Router, Request, Response } from 'express';
import { authService } from '../services/auth';
import { prisma } from '../db/prisma';
const router = Router();
router.post('/challenge', async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey || typeof pubkey !== 'string') {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const challenge = authService.createChallenge(pubkey);
res.json({ challenge });
} catch (err) {
console.error('Challenge error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post('/verify', async (req: Request, res: Response) => {
try {
const { pubkey, signedEvent } = req.body;
if (!pubkey || !signedEvent) {
res.status(400).json({ error: 'pubkey and signedEvent are required' });
return;
}
const valid = authService.verifySignature(pubkey, signedEvent);
if (!valid) {
res.status(401).json({ error: 'Invalid signature or expired challenge' });
return;
}
const role = await authService.getRole(pubkey);
const dbUser = await prisma.user.upsert({
where: { pubkey },
update: { role },
create: { pubkey, role },
});
const token = authService.generateToken(pubkey, role);
res.json({ token, user: { pubkey, role, username: dbUser.username ?? undefined } });
} catch (err) {
console.error('Verify error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

163
backend/src/api/calendar.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
const router = Router();
function escapeIcs(text: string): string {
return text
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n')
.replace(/\r/g, '');
}
// ICS lines must be folded at 75 octets (RFC 5545 §3.1)
function fold(line: string): string {
const MAX = 75;
if (line.length <= MAX) return line;
let out = '';
let pos = 0;
while (pos < line.length) {
if (pos === 0) {
out += line.slice(0, MAX);
pos = MAX;
} else {
out += '\r\n ' + line.slice(pos, pos + MAX - 1);
pos += MAX - 1;
}
}
return out;
}
function toIcsDate(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return (
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
`T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`
);
}
// Parse "HH:MM", "H:MM am/pm", "Hpm" etc.
function parseLocalTime(t: string): { h: number; m: number } {
const clean = t.trim();
const m24 = clean.match(/^(\d{1,2}):(\d{2})$/);
if (m24) return { h: parseInt(m24[1]), m: parseInt(m24[2]) };
const mAp = clean.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
if (mAp) {
let h = parseInt(mAp[1]);
const m = mAp[2] ? parseInt(mAp[2]) : 0;
if (mAp[3].toLowerCase() === 'pm' && h !== 12) h += 12;
if (mAp[3].toLowerCase() === 'am' && h === 12) h = 0;
return { h, m };
}
return { h: 18, m: 0 };
}
// Brussels is UTC+1 (CET) / UTC+2 (CEST). Use +1 as conservative default.
const BRUSSELS_OFFSET_HOURS = 1;
function parseEventDates(
dateStr: string,
timeStr: string
): { start: Date; end: Date } {
const [year, month, day] = dateStr.split('-').map(Number);
const parts = timeStr.split(/\s*[-]\s*/);
const { h: startH, m: startM } = parseLocalTime(parts[0]);
// Convert local Brussels time to UTC
const utcStartH = startH - BRUSSELS_OFFSET_HOURS;
const start = new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
let end: Date;
if (parts[1]) {
const { h: endH, m: endM } = parseLocalTime(parts[1]);
const utcEndH = endH - BRUSSELS_OFFSET_HOURS;
end = new Date(Date.UTC(year, month - 1, day, utcEndH, endM, 0));
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
} else {
end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
}
return { start, end };
}
router.get('/ics', async (_req: Request, res: Response) => {
try {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const cutoff = sevenDaysAgo.toISOString().slice(0, 10);
const meetups = await prisma.meetup.findMany({
where: { date: { gte: cutoff } },
orderBy: { date: 'asc' },
});
const siteUrl = (process.env.FRONTEND_URL || 'https://belgianbitcoinembassy.org').replace(
/\/$/,
''
);
const now = new Date();
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Belgian Bitcoin Embassy//Events//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
fold('X-WR-CALNAME:Belgian Bitcoin Embassy Events'),
fold('X-WR-CALDESC:Upcoming meetups and events by the Belgian Bitcoin Embassy'),
'X-WR-TIMEZONE:Europe/Brussels',
];
for (const meetup of meetups) {
try {
const { start, end } = parseEventDates(meetup.date, meetup.time);
const eventUrl = meetup.link || `${siteUrl}/events/${meetup.id}`;
lines.push('BEGIN:VEVENT');
lines.push(fold(`UID:${meetup.id}@belgianbitcoinembassy.org`));
lines.push(`DTSTAMP:${toIcsDate(now)}`);
lines.push(`DTSTART:${toIcsDate(start)}`);
lines.push(`DTEND:${toIcsDate(end)}`);
lines.push(fold(`SUMMARY:${escapeIcs(meetup.title)}`));
if (meetup.description) {
lines.push(fold(`DESCRIPTION:${escapeIcs(meetup.description)}`));
}
if (meetup.location) {
lines.push(fold(`LOCATION:${escapeIcs(meetup.location)}`));
}
lines.push(fold(`URL:${eventUrl}`));
lines.push(
'ORGANIZER;CN=Belgian Bitcoin Embassy:mailto:info@belgianbitcoinembassy.org'
);
// 15-minute reminder alarm
lines.push('BEGIN:VALARM');
lines.push('TRIGGER:-PT15M');
lines.push('ACTION:DISPLAY');
lines.push(fold(`DESCRIPTION:Reminder: ${escapeIcs(meetup.title)}`));
lines.push('END:VALARM');
lines.push('END:VEVENT');
} catch {
// Skip events with unparseable dates
}
}
lines.push('END:VCALENDAR');
const icsBody = lines.join('\r\n') + '\r\n';
res.set({
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'inline; filename="bbe-events.ics"',
'Cache-Control': 'public, max-age=300',
});
res.send(icsBody);
} catch (err) {
console.error('Calendar ICS error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -0,0 +1,103 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.get('/', async (_req: Request, res: Response) => {
try {
const categories = await prisma.category.findMany({
orderBy: { sortOrder: 'asc' },
});
res.json(categories);
} catch (err) {
console.error('List categories error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { name, slug, sortOrder } = req.body;
if (!name || !slug) {
res.status(400).json({ error: 'name and slug are required' });
return;
}
const category = await prisma.category.create({
data: {
name,
slug,
sortOrder: sortOrder || 0,
},
});
res.status(201).json(category);
} catch (err) {
console.error('Create category error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
where: { id: req.params.id as string },
});
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
const { name, slug, sortOrder } = req.body;
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (slug !== undefined) updateData.slug = slug;
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
const updated = await prisma.category.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err) {
console.error('Update category error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const category = await prisma.category.findUnique({
where: { id: req.params.id as string },
});
if (!category) {
res.status(404).json({ error: 'Category not found' });
return;
}
await prisma.category.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete category error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

155
backend/src/api/faqs.ts Normal file
View File

@@ -0,0 +1,155 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
// Public: get FAQs (homepage-visible only by default; pass ?all=true for all)
router.get('/', async (req: Request, res: Response) => {
try {
const showAll = req.query.all === 'true';
const faqs = await prisma.faq.findMany({
where: showAll ? undefined : { showOnHomepage: true },
orderBy: { order: 'asc' },
});
res.json(faqs);
} catch (err) {
console.error('List public FAQs error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Admin: get all FAQs regardless of visibility
router.get(
'/all',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (_req: Request, res: Response) => {
try {
const faqs = await prisma.faq.findMany({
orderBy: { order: 'asc' },
});
res.json(faqs);
} catch (err) {
console.error('List all FAQs error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Admin: create FAQ
router.post(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { question, answer, showOnHomepage } = req.body;
if (!question || !answer) {
res.status(400).json({ error: 'question and answer are required' });
return;
}
const maxOrder = await prisma.faq.aggregate({ _max: { order: true } });
const nextOrder = (maxOrder._max.order ?? -1) + 1;
const faq = await prisma.faq.create({
data: {
question,
answer,
order: nextOrder,
showOnHomepage: showOnHomepage !== undefined ? showOnHomepage : true,
},
});
res.status(201).json(faq);
} catch (err) {
console.error('Create FAQ error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Admin: update FAQ
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
if (!faq) {
res.status(404).json({ error: 'FAQ not found' });
return;
}
const { question, answer, showOnHomepage } = req.body;
const updateData: any = {};
if (question !== undefined) updateData.question = question;
if (answer !== undefined) updateData.answer = answer;
if (showOnHomepage !== undefined) updateData.showOnHomepage = showOnHomepage;
const updated = await prisma.faq.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err) {
console.error('Update FAQ error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Admin: delete FAQ
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const faq = await prisma.faq.findUnique({ where: { id: req.params.id as string } });
if (!faq) {
res.status(404).json({ error: 'FAQ not found' });
return;
}
await prisma.faq.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete FAQ error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
// Admin: reorder FAQs — accepts array of { id, order }
router.post(
'/reorder',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { items } = req.body as { items: { id: string; order: number }[] };
if (!Array.isArray(items)) {
res.status(400).json({ error: 'items array is required' });
return;
}
await Promise.all(
items.map(({ id, order }) =>
prisma.faq.update({ where: { id }, data: { order } })
)
);
res.json({ success: true });
} catch (err) {
console.error('Reorder FAQs error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

217
backend/src/api/media.ts Normal file
View File

@@ -0,0 +1,217 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { ulid } from 'ulid';
import slugify from 'slugify';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH
|| path.resolve(__dirname, '../../../storage/media');
function ensureStorageDir() {
fs.mkdirSync(STORAGE_PATH, { recursive: true });
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
});
const IMAGE_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const VIDEO_MIMES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
const ALLOWED_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES];
function getMediaType(mimeType: string): 'image' | 'video' | null {
if (IMAGE_MIMES.includes(mimeType)) return 'image';
if (VIDEO_MIMES.includes(mimeType)) return 'video';
return null;
}
function makeSlug(filename: string): string {
const name = path.parse(filename).name;
return slugify(name, { lower: true, strict: true }) || 'media';
}
const router = Router();
router.post(
'/upload',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
upload.single('file'),
async (req: Request, res: Response) => {
try {
const file = req.file;
if (!file) {
res.status(400).json({ error: 'No file provided' });
return;
}
if (!ALLOWED_MIMES.includes(file.mimetype)) {
res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` });
return;
}
const mediaType = getMediaType(file.mimetype);
if (!mediaType) {
res.status(400).json({ error: 'Could not determine media type' });
return;
}
const id = ulid();
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,
type: mediaType,
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,
},
});
res.status(201).json({
id: media.id,
slug: media.slug,
url: `/media/${media.id}`,
});
} catch (err) {
console.error('Upload media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get('/', async (_req: Request, res: Response) => {
try {
const media = await prisma.media.findMany({
orderBy: { createdAt: 'desc' },
});
const result = media.map((m) => ({
...m,
url: `/media/${m.id}`,
}));
res.json(result);
} catch (err) {
console.error('List media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/:id', async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
where: { id: req.params.id as string },
});
if (!media) {
res.status(404).json({ error: 'Media not found' });
return;
}
res.json({ ...media, url: `/media/${media.id}` });
} catch (err) {
console.error('Get media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
where: { id: req.params.id as string },
});
if (!media) {
res.status(404).json({ error: 'Media not found' });
return;
}
const { title, description, altText } = req.body;
const updateData: any = {};
if (title !== undefined) {
updateData.title = title || null;
updateData.slug = title ? makeSlug(title) : media.slug;
}
if (description !== undefined) updateData.description = description || null;
if (altText !== undefined) updateData.altText = altText || null;
const updated = await prisma.media.update({
where: { id: media.id },
data: updateData,
});
res.json({ ...updated, url: `/media/${updated.id}` });
} catch (err) {
console.error('Update media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const media = await prisma.media.findUnique({
where: { id: req.params.id as string },
});
if (!media) {
res.status(404).json({ error: 'Media not found' });
return;
}
const filePath = path.join(STORAGE_PATH, media.id);
const metaPath = path.join(STORAGE_PATH, `${media.id}.json`);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
// Clean up any cached resized versions
const cachePath = path.join(STORAGE_PATH, 'cache');
if (fs.existsSync(cachePath)) {
const cached = fs.readdirSync(cachePath)
.filter((f) => f.startsWith(media.id));
for (const f of cached) {
fs.unlinkSync(path.join(cachePath, f));
}
}
await prisma.media.delete({ where: { id: media.id } });
res.json({ success: true });
} catch (err) {
console.error('Delete media error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

253
backend/src/api/meetups.ts Normal file
View File

@@ -0,0 +1,253 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
function incrementTitle(title: string): string {
const match = title.match(/^(.*#)(\d+)(.*)$/);
if (match) {
const num = parseInt(match[2], 10);
return `${match[1]}${num + 1}${match[3]}`;
}
return `${title} (copy)`;
}
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 = {};
if (status) where.status = status;
if (!admin) where.visibility = 'PUBLIC';
const meetups = await prisma.meetup.findMany({
where,
orderBy: { date: 'asc' },
});
res.json(meetups);
} catch (err) {
console.error('List meetups error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/:id', async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
});
if (!meetup) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
res.json(meetup);
} catch (err) {
console.error('Get meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
if (!title || !description || !date || !time || !location) {
res
.status(400)
.json({ error: 'title, description, date, time, and location are required' });
return;
}
const meetup = await prisma.meetup.create({
data: {
title,
description,
date,
time,
location,
link: link || null,
imageId: imageId || null,
status: status || 'DRAFT',
featured: featured || false,
visibility: visibility || 'PUBLIC',
},
});
res.status(201).json(meetup);
} catch (err) {
console.error('Create meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/bulk',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { action, ids } = req.body as { action: string; ids: string[] };
if (!action || !Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'action and ids are required' });
return;
}
if (action === 'delete') {
await prisma.meetup.deleteMany({ where: { id: { in: ids } } });
res.json({ success: true, affected: ids.length });
return;
}
if (action === 'publish') {
await prisma.meetup.updateMany({
where: { id: { in: ids } },
data: { status: 'PUBLISHED' },
});
res.json({ success: true, affected: ids.length });
return;
}
if (action === 'duplicate') {
const originals = await prisma.meetup.findMany({ where: { id: { in: ids } } });
const created = await Promise.all(
originals.map((m) =>
prisma.meetup.create({
data: {
title: incrementTitle(m.title),
description: m.description,
date: '',
time: '',
location: m.location,
link: m.link || null,
imageId: m.imageId || null,
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
},
})
)
);
res.json(created);
return;
}
res.status(400).json({ error: 'Unknown action' });
} catch (err) {
console.error('Bulk meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/:id/duplicate',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const original = await prisma.meetup.findUnique({ where: { id: req.params.id as string } });
if (!original) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
const duplicate = await prisma.meetup.create({
data: {
title: incrementTitle(original.title),
description: original.description,
date: '',
time: '',
location: original.location,
link: original.link || null,
imageId: original.imageId || null,
status: 'DRAFT',
featured: false,
visibility: 'PUBLIC',
},
});
res.status(201).json(duplicate);
} catch (err) {
console.error('Duplicate meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
});
if (!meetup) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
const { title, description, date, time, location, link, status, featured, imageId, visibility } =
req.body;
const updateData: any = {};
if (title !== undefined) updateData.title = title;
if (description !== undefined) updateData.description = description;
if (date !== undefined) updateData.date = date;
if (time !== undefined) updateData.time = time;
if (location !== undefined) updateData.location = location;
if (link !== undefined) updateData.link = link;
if (status !== undefined) updateData.status = status;
if (featured !== undefined) updateData.featured = featured;
if (imageId !== undefined) updateData.imageId = imageId;
if (visibility !== undefined) updateData.visibility = visibility;
const updated = await prisma.meetup.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err) {
console.error('Update meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const meetup = await prisma.meetup.findUnique({
where: { id: req.params.id as string },
});
if (!meetup) {
res.status(404).json({ error: 'Meetup not found' });
return;
}
await prisma.meetup.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete meetup error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -0,0 +1,143 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.get(
'/hidden',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (_req: Request, res: Response) => {
try {
const hidden = await prisma.hiddenContent.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(hidden);
} catch (err) {
console.error('List hidden content error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/hide',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { nostrEventId, reason } = req.body;
if (!nostrEventId) {
res.status(400).json({ error: 'nostrEventId is required' });
return;
}
const hidden = await prisma.hiddenContent.create({
data: {
nostrEventId,
reason: reason || null,
hiddenBy: req.user!.pubkey,
},
});
res.status(201).json(hidden);
} catch (err) {
console.error('Hide content error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/unhide/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const item = await prisma.hiddenContent.findUnique({
where: { id: req.params.id as string },
});
if (!item) {
res.status(404).json({ error: 'Hidden content not found' });
return;
}
await prisma.hiddenContent.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Unhide content error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/blocked',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (_req: Request, res: Response) => {
try {
const blocked = await prisma.blockedPubkey.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(blocked);
} catch (err) {
console.error('List blocked pubkeys error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/block',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { pubkey, reason } = req.body;
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const blocked = await prisma.blockedPubkey.create({
data: {
pubkey,
reason: reason || null,
blockedBy: req.user!.pubkey,
},
});
res.status(201).json(blocked);
} catch (err) {
console.error('Block pubkey error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/unblock/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const item = await prisma.blockedPubkey.findUnique({
where: { id: req.params.id as string },
});
if (!item) {
res.status(404).json({ error: 'Blocked pubkey not found' });
return;
}
await prisma.blockedPubkey.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Unblock pubkey error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

32
backend/src/api/nip05.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
const router = Router();
router.get('/', async (req: Request, res: Response) => {
try {
const nameFilter = req.query.name as string | undefined;
const where = nameFilter
? { username: nameFilter.toLowerCase() }
: { username: { not: null } };
const users = await prisma.user.findMany({ where: where as any });
const names: Record<string, string> = {};
for (const user of users) {
if (user.username) {
names[user.username] = user.pubkey;
}
}
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
res.json({ names });
} catch (err) {
console.error('NIP-05 error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

88
backend/src/api/nostr.ts Normal file
View File

@@ -0,0 +1,88 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
router.post(
'/fetch',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
if (!eventId && !naddr) {
res.status(400).json({ error: 'eventId or naddr is required' });
return;
}
let event = 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;
}
res.json(event);
} catch (err) {
console.error('Fetch event error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/cache/refresh',
requireAuth,
requireRole(['ADMIN']),
async (_req: Request, res: Response) => {
try {
const cachedEvents = await prisma.nostrEventCache.findMany();
let refreshed = 0;
for (const cached of cachedEvents) {
const event = await nostrService.fetchEvent(cached.eventId, true);
if (event) refreshed++;
}
res.json({ refreshed, total: cachedEvents.length });
} catch (err) {
console.error('Cache refresh error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/debug/:eventId',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const cached = await prisma.nostrEventCache.findUnique({
where: { eventId: req.params.eventId as string },
});
if (!cached) {
res.status(404).json({ error: 'Event not found in cache' });
return;
}
res.json({
...cached,
tags: JSON.parse(cached.tags),
});
} catch (err) {
console.error('Debug event error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

243
backend/src/api/posts.ts Normal file
View File

@@ -0,0 +1,243 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import { nostrService } from '../services/nostr';
const router = Router();
router.get('/', async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 20;
const category = req.query.category as string | undefined;
const skip = (page - 1) * limit;
const where: any = { visible: true };
if (category) {
where.categories = {
some: { category: { slug: category } },
};
}
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
include: {
categories: { include: { category: true } },
},
orderBy: { publishedAt: 'desc' },
skip,
take: limit,
}),
prisma.post.count({ where }),
]);
res.json({
posts,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
} catch (err) {
console.error('List posts error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.get('/:slug', async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({
where: { slug: req.params.slug as string },
include: {
categories: { include: { category: true } },
},
});
if (!post) {
res.status(404).json({ error: 'Post not found' });
return;
}
res.json(post);
} catch (err) {
console.error('Get post error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.post(
'/import',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { eventId, naddr } = req.body;
if (!eventId && !naddr) {
res.status(400).json({ error: 'eventId or naddr is 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),
},
});
res.json(post);
} catch (err) {
console.error('Import post error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { title, slug, excerpt, featured, visible, categories } = req.body;
const post = await prisma.post.findUnique({ where: { id: req.params.id as string } });
if (!post) {
res.status(404).json({ error: 'Post not found' });
return;
}
const updateData: any = {};
if (title !== undefined) updateData.title = title;
if (slug !== undefined) updateData.slug = slug;
if (excerpt !== undefined) updateData.excerpt = excerpt;
if (featured !== undefined) updateData.featured = featured;
if (visible !== undefined) updateData.visible = visible;
const updated = await prisma.post.update({
where: { id: req.params.id as string },
data: updateData,
});
if (categories && Array.isArray(categories)) {
await prisma.postCategory.deleteMany({
where: { postId: post.id },
});
await prisma.postCategory.createMany({
data: categories.map((categoryId: string) => ({
postId: post.id,
categoryId,
})),
});
}
const result = await prisma.post.findUnique({
where: { id: updated.id },
include: { categories: { include: { category: true } } },
});
res.json(result);
} catch (err) {
console.error('Update post error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
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) {
res.status(404).json({ error: 'Post not found' });
return;
}
const reactions = await nostrService.fetchReactions(post.nostrEventId);
res.json({ count: reactions.length, reactions });
} catch (err) {
console.error('Get reactions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
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) {
res.status(404).json({ error: 'Post not found' });
return;
}
const [replies, hiddenContent, blockedPubkeys] = await Promise.all([
nostrService.fetchReplies(post.nostrEventId),
prisma.hiddenContent.findMany({ select: { nostrEventId: true } }),
prisma.blockedPubkey.findMany({ select: { pubkey: true } }),
]);
const hiddenIds = new Set(hiddenContent.map((h) => h.nostrEventId));
const blockedKeys = new Set(blockedPubkeys.map((b) => b.pubkey));
const filtered = replies.filter(
(r) => !hiddenIds.has(r.id) && !blockedKeys.has(r.pubkey)
);
res.json({ count: filtered.length, replies: filtered });
} catch (err) {
console.error('Get replies error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const post = await prisma.post.findUnique({ where: { id: req.params.id as string } });
if (!post) {
res.status(404).json({ error: 'Post not found' });
return;
}
await prisma.post.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete post error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

141
backend/src/api/relays.ts Normal file
View File

@@ -0,0 +1,141 @@
import { Router, Request, Response } from 'express';
import { SimplePool } from 'nostr-tools';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
async (_req: Request, res: Response) => {
try {
const relays = await prisma.relay.findMany({
orderBy: { priority: 'asc' },
});
res.json(relays);
} catch (err) {
console.error('List relays error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { url, priority } = req.body;
if (!url) {
res.status(400).json({ error: 'url is required' });
return;
}
const relay = await prisma.relay.create({
data: {
url,
priority: priority || 0,
},
});
res.status(201).json(relay);
} catch (err) {
console.error('Create relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
where: { id: req.params.id as string },
});
if (!relay) {
res.status(404).json({ error: 'Relay not found' });
return;
}
const { url, priority, active } = req.body;
const updateData: any = {};
if (url !== undefined) updateData.url = url;
if (priority !== undefined) updateData.priority = priority;
if (active !== undefined) updateData.active = active;
const updated = await prisma.relay.update({
where: { id: req.params.id as string },
data: updateData,
});
res.json(updated);
} catch (err) {
console.error('Update relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.delete(
'/:id',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
where: { id: req.params.id as string },
});
if (!relay) {
res.status(404).json({ error: 'Relay not found' });
return;
}
await prisma.relay.delete({ where: { id: req.params.id as string } });
res.json({ success: true });
} catch (err) {
console.error('Delete relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/:id/test',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const relay = await prisma.relay.findUnique({
where: { id: req.params.id as string },
});
if (!relay) {
res.status(404).json({ error: 'Relay not found' });
return;
}
const pool = new SimplePool();
const startTime = Date.now();
try {
await pool.get([relay.url], { kinds: [1], limit: 1 });
const latency = Date.now() - startTime;
res.json({ success: true, latency, url: relay.url });
} catch {
res.json({ success: false, error: 'Connection failed', url: relay.url });
} finally {
pool.close([relay.url]);
}
} catch (err) {
console.error('Test relay error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -0,0 +1,79 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
const PUBLIC_SETTINGS = [
'site_title',
'site_tagline',
'telegram_link',
'nostr_link',
'x_link',
'youtube_link',
'discord_link',
'linkedin_link',
];
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const settings = await prisma.setting.findMany();
const result: Record<string, string> = {};
for (const s of settings) {
result[s.key] = s.value;
}
res.json(result);
} catch (err) {
console.error('List settings error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get('/public', async (_req: Request, res: Response) => {
try {
const settings = await prisma.setting.findMany({
where: { key: { in: PUBLIC_SETTINGS } },
});
const result: Record<string, string> = {};
for (const s of settings) {
result[s.key] = s.value;
}
res.json(result);
} catch (err) {
console.error('Public settings error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
router.patch(
'/',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { key, value } = req.body;
if (!key || value === undefined) {
res.status(400).json({ error: 'key and value are required' });
return;
}
const setting = await prisma.setting.upsert({
where: { key },
update: { value },
create: { key, value },
});
res.json(setting);
} catch (err) {
console.error('Update setting error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -0,0 +1,114 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const router = Router();
router.post(
'/',
requireAuth,
async (req: Request, res: Response) => {
try {
const { eventId, naddr, title } = req.body;
if (!title || (!eventId && !naddr)) {
res.status(400).json({ error: 'title and either eventId or naddr are required' });
return;
}
const submission = await prisma.submission.create({
data: {
eventId: eventId || null,
naddr: naddr || null,
title,
authorPubkey: req.user!.pubkey,
},
});
res.status(201).json(submission);
} catch (err) {
console.error('Create submission error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/mine',
requireAuth,
async (req: Request, res: Response) => {
try {
const submissions = await prisma.submission.findMany({
where: { authorPubkey: req.user!.pubkey },
orderBy: { createdAt: 'desc' },
});
res.json(submissions);
} catch (err) {
console.error('List own submissions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const status = req.query.status as string | undefined;
const where: any = {};
if (status) where.status = status;
const submissions = await prisma.submission.findMany({
where,
orderBy: { createdAt: 'desc' },
});
res.json(submissions);
} catch (err) {
console.error('List submissions error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/:id',
requireAuth,
requireRole(['ADMIN', 'MODERATOR']),
async (req: Request, res: Response) => {
try {
const { status, reviewNote } = req.body;
if (!status || !['APPROVED', 'REJECTED'].includes(status)) {
res.status(400).json({ error: 'status must be APPROVED or REJECTED' });
return;
}
const submission = await prisma.submission.findUnique({
where: { id: req.params.id as string },
});
if (!submission) {
res.status(404).json({ error: 'Submission not found' });
return;
}
const updated = await prisma.submission.update({
where: { id: req.params.id as string },
data: {
status,
reviewedBy: req.user!.pubkey,
reviewNote: reviewNote || null,
},
});
res.json(updated);
} catch (err) {
console.error('Review submission error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

177
backend/src/api/users.ts Normal file
View File

@@ -0,0 +1,177 @@
import { Router, Request, Response } from 'express';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
import fs from 'fs';
import path from 'path';
const BLOCKED_USERNAMES_PATH = path.resolve(__dirname, '../../config/blocked-usernames.txt');
function getBlockedUsernames(): Set<string> {
try {
const content = fs.readFileSync(BLOCKED_USERNAMES_PATH, 'utf-8');
return new Set(
content
.split('\n')
.map((l) => l.trim().toLowerCase())
.filter(Boolean)
);
} catch {
return new Set();
}
}
const USERNAME_REGEX = /^[a-z0-9._-]+$/i;
function validateUsername(username: string): string | null {
if (!username || username.trim().length === 0) return 'Username is required';
if (username.length > 50) return 'Username must be 50 characters or fewer';
if (!USERNAME_REGEX.test(username)) return 'Username may only contain letters, numbers, dots, hyphens, and underscores';
const blocked = getBlockedUsernames();
if (blocked.has(username.toLowerCase())) return 'This username is reserved';
return null;
}
const router = Router();
router.get(
'/',
requireAuth,
requireRole(['ADMIN']),
async (_req: Request, res: Response) => {
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
});
res.json(users);
} catch (err) {
console.error('List users error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/promote',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey },
update: { role: 'MODERATOR' },
create: { pubkey, role: 'MODERATOR' },
});
res.json(user);
} catch (err) {
console.error('Promote user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.post(
'/demote',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const { pubkey } = req.body;
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey },
update: { role: 'USER' },
create: { pubkey, role: 'USER' },
});
res.json(user);
} catch (err) {
console.error('Demote user error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.get(
'/me/username-check',
requireAuth,
async (req: Request, res: Response) => {
try {
const username = (req.query.username as string || '').trim().toLowerCase();
const error = validateUsername(username);
if (error) {
res.json({ available: false, reason: error });
return;
}
const existing = await prisma.user.findFirst({
where: {
username: { equals: username },
NOT: { pubkey: req.user!.pubkey },
},
});
if (existing) {
res.json({ available: false, reason: 'Username is already taken' });
return;
}
res.json({ available: true });
} catch (err) {
console.error('Username check error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
router.patch(
'/me',
requireAuth,
async (req: Request, res: Response) => {
try {
const { username } = req.body;
const normalized = (username as string || '').trim().toLowerCase();
const error = validateUsername(normalized);
if (error) {
res.status(400).json({ error });
return;
}
const existing = await prisma.user.findFirst({
where: {
username: { equals: normalized },
NOT: { pubkey: req.user!.pubkey },
},
});
if (existing) {
res.status(409).json({ error: 'Username is already taken' });
return;
}
const user = await prisma.user.upsert({
where: { pubkey: req.user!.pubkey },
update: { username: normalized },
create: { pubkey: req.user!.pubkey, username: normalized },
});
res.json(user);
} catch (err) {
console.error('Update profile error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

9
backend/src/db/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

71
backend/src/index.ts Normal file
View File

@@ -0,0 +1,71 @@
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import authRouter from './api/auth';
import postsRouter from './api/posts';
import meetupsRouter from './api/meetups';
import moderationRouter from './api/moderation';
import usersRouter from './api/users';
import categoriesRouter from './api/categories';
import relaysRouter from './api/relays';
import settingsRouter from './api/settings';
import nostrRouter from './api/nostr';
import submissionsRouter from './api/submissions';
import mediaRouter from './api/media';
import faqsRouter from './api/faqs';
import calendarRouter from './api/calendar';
import nip05Router from './api/nip05';
const app = express();
const PORT = parseInt(process.env.BACKEND_PORT || '4000', 10);
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
// Trust the first proxy (nginx) so req.ip returns the real client IP
app.set('trust proxy', 1);
app.use(helmet());
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use(cors({ origin: FRONTEND_URL, credentials: true }));
app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/posts', postsRouter);
app.use('/api/meetups', meetupsRouter);
app.use('/api/moderation', moderationRouter);
app.use('/api/users', usersRouter);
app.use('/api/categories', categoriesRouter);
app.use('/api/relays', relaysRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/nostr', nostrRouter);
app.use('/api/submissions', submissionsRouter);
app.use('/api/media', mediaRouter);
app.use('/api/faqs', faqsRouter);
app.use('/api/calendar', calendarRouter);
app.use('/api/nip05', nip05Router);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
const server = app.listen(PORT, () => {
console.log(`Backend running on http://localhost:${PORT}`);
});
const shutdown = () => {
console.log('Shutting down gracefully…');
server.close(() => {
console.log('Server closed.');
process.exit(0);
});
// Force exit if connections don't drain within 10 seconds
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

View File

@@ -0,0 +1,48 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
export interface AuthPayload {
pubkey: string;
role: string;
}
declare global {
namespace Express {
interface Request {
user?: AuthPayload;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid authorization header' });
return;
}
const token = header.slice(7);
try {
const payload = jwt.verify(token, JWT_SECRET) as AuthPayload;
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
export function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (!roles.includes(req.user.role)) {
res.status(403).json({ error: 'Insufficient permissions' });
return;
}
next();
};
}

View File

@@ -0,0 +1,90 @@
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
interface StoredChallenge {
challenge: string;
expiresAt: number;
}
const challenges = new Map<string, StoredChallenge>();
// Periodically clean up expired challenges
setInterval(() => {
const now = Date.now();
for (const [key, value] of challenges) {
if (value.expiresAt < now) {
challenges.delete(key);
}
}
}, 60_000);
export const authService = {
createChallenge(pubkey: string): string {
const challenge = uuidv4();
challenges.set(pubkey, {
challenge,
expiresAt: Date.now() + CHALLENGE_TTL_MS,
});
return challenge;
},
verifySignature(pubkey: string, signedEvent: VerifiedEvent): boolean {
const stored = challenges.get(pubkey);
if (!stored) return false;
if (stored.expiresAt < Date.now()) {
challenges.delete(pubkey);
return false;
}
// Verify the event signature
if (!verifyEvent(signedEvent)) return false;
// Kind 22242 is the NIP-42 auth kind
if (signedEvent.kind !== 22242) return false;
if (signedEvent.pubkey !== pubkey) return false;
// Check that the challenge tag matches
const challengeTag = signedEvent.tags.find(
(t) => t[0] === 'challenge'
);
if (!challengeTag || challengeTag[1] !== stored.challenge) return false;
challenges.delete(pubkey);
return true;
},
generateToken(pubkey: string, role: string): string {
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;
});
if (adminPubkeys.includes(pubkey)) return 'ADMIN';
const user = await prisma.user.findUnique({ where: { pubkey } });
if (user?.role === 'MODERATOR') return 'MODERATOR';
if (user?.role === 'ADMIN') return 'ADMIN';
return 'USER';
},
};

View File

@@ -0,0 +1,146 @@
import { SimplePool, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
const pool = new SimplePool();
async function getRelayUrls(): Promise<string[]> {
const relays = await prisma.relay.findMany({
where: { active: true },
orderBy: { priority: 'asc' },
});
return relays.map((r) => r.url);
}
export const nostrService = {
async fetchEvent(eventId: string, skipCache = false) {
if (!skipCache) {
const cached = await prisma.nostrEventCache.findUnique({
where: { eventId },
});
if (cached) {
return {
id: cached.eventId,
kind: cached.kind,
pubkey: cached.pubkey,
content: cached.content,
tags: JSON.parse(cached.tags),
created_at: cached.createdAt,
};
}
}
const relays = await getRelayUrls();
if (relays.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;
} catch (err) {
console.error('Failed to fetch event:', err);
return null;
}
},
async fetchLongformEvent(naddrStr: string) {
let decoded: nip19.AddressPointer;
try {
const result = nip19.decode(naddrStr);
if (result.type !== 'naddr') return null;
decoded = result.data;
} catch {
return null;
}
const relays = decoded.relays?.length
? decoded.relays
: await getRelayUrls();
if (relays.length === 0) return null;
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);
return null;
}
},
async fetchReactions(eventId: string) {
const relays = await getRelayUrls();
if (relays.length === 0) return [];
try {
const events = await pool.querySync(relays, {
kinds: [7],
'#e': [eventId],
});
return events;
} catch (err) {
console.error('Failed to fetch reactions:', err);
return [];
}
},
async fetchReplies(eventId: string) {
const relays = await getRelayUrls();
if (relays.length === 0) return [];
try {
const events = await pool.querySync(relays, {
kinds: [1],
'#e': [eventId],
});
return events;
} catch (err) {
console.error('Failed to fetch replies:', err);
return [];
}
},
async getRelays() {
return prisma.relay.findMany({
where: { active: true },
orderBy: { priority: 'asc' },
});
},
};

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

78
context/design.md Normal file
View File

@@ -0,0 +1,78 @@
# Design System Specification: The Sovereign Editorial
## 1. Overview & Creative North Star
The "Sovereign Editorial" is the creative North Star for this design system. It moves away from the chaotic, high-frequency aesthetic of "crypto-hype" and instead leans into the quiet authority of a diplomatic institution. This is not a "trading platform"; it is an Embassy.
The system breaks the standard "web template" look by utilizing **Intentional Asymmetry** and **Tonal Depth**. We prioritize a high-end, editorial feel through expansive whitespace (using the `20` and `24` spacing tokens), overlapping elements that break the grid, and a typography scale that treats information as a curated exhibition rather than a data dump.
---
## 2. Colors: Tonal Depth & The "No-Line" Rule
The palette is rooted in a "Rich Black" foundation, using `surface` (`#131313`) as the canvas.
### The "No-Line" Rule
**Explicit Instruction:** 1px solid borders for sectioning are strictly prohibited.
Structure must be defined solely through:
- **Background Shifts:** Placing a `surface-container-low` section against a `surface` background.
- **Negative Space:** Using large gaps from the Spacing Scale (e.g., `12` or `16`) to imply boundaries.
### Surface Hierarchy & Nesting
Treat the UI as a series of physical layers, like stacked sheets of obsidian glass.
- **Base Layer:** `surface` (`#131313`)
- **Secondary Sectioning:** `surface-container-low` (`#1c1b1b`)
- **Interactive/Floating Cards:** `surface-container-high` (`#2a2a2a`)
- **The "Glass & Gradient" Rule:** For primary CTAs and hero highlights, use a subtle linear gradient from `primary` (`#ffb874`) to `primary-container` (`#f7931a`). Floating navigation or "pinned" elements must use `surface-bright` with a 15px backdrop-blur to create a premium glassmorphism effect.
---
## 3. Typography: Authority Through Scale
We use **Inter** (as the modern web equivalent to San Francisco) to provide a neutral, trustworthy foundation. The hierarchy is designed to feel like a high-end broadsheet.
- **Display (The Statement):** Use `display-lg` (3.5rem) for hero statements. Tighten letter-spacing to `-0.02em` for a "premium print" look.
- **Headline (The Narrative):** `headline-lg` (2rem) and `headline-md` (1.75rem) provide the educational structure.
- **Body (The Education):** `body-lg` (1rem) is the workhorse. We prioritize a line-height of 1.6 to ensure readability for long-form educational content.
- **Labels (The Metadata):** `label-md` (0.75rem) should be used in uppercase with `0.05em` letter spacing for a technical, diplomatic feel.
---
## 4. Elevation & Depth: Tonal Layering
Traditional drop shadows are replaced by **Ambient Occlusion** and **Material Stacking**.
- **The Layering Principle:** To lift a card, do not add a shadow. Instead, place a `surface-container-lowest` (`#0e0e0e`) element inside a `surface-container` (`#201f1f`) section. The shift in value creates a natural perception of depth.
- **Ambient Shadows:** When a "floating" modal is necessary, use a blur of `40px` with an 8% opacity of the `on-surface` color. It should feel like a soft glow rather than a harsh shadow.
- **The "Ghost Border" Fallback:** If accessibility requires a stroke (e.g., input fields), use `outline-variant` at 15% opacity. Never use 100% opaque borders.
---
## 5. Components
### Buttons
- **Primary:** Gradient background (`primary` to `primary_container`), `on-primary` text. Shape: `md` (0.75rem) corner radius.
- **Secondary:** `surface-container-highest` background with `on-surface` text. No border.
- **Tertiary:** Text-only using `primary_fixed_dim`, strictly for low-priority actions.
### Cards & Lists
- **The Divider Ban:** Dividers are forbidden. Separate list items using `spacing-4` (1.4rem) of vertical whitespace or by alternating background colors between `surface-container-low` and `surface-container-lowest`.
- **Cards:** Always use `rounded-lg` (1rem). Content should have a minimum internal padding of `spacing-6` (2rem).
### Input Fields
- **State:** Background should be `surface-container-highest`.
- **Focus:** Transition the "Ghost Border" from 15% to 40% opacity of the `primary` color. Do not use heavy glow effects.
### Signature Components for the Embassy
- **The "Knowledge Card":** A large-format card using `surface-container-low` with an asymmetrical layout—typography pushed to the left, and a subtle, desaturated Bitcoin icon overlapping the right edge at 5% opacity.
- **The "Trust Indicator":** A persistent, glassmorphic "Status" bar at the bottom of mobile screens, providing real-time network reassurance without cluttering the main content.
---
## 6. Dos and Donts
### Do:
- **Use "Aggressive" Whitespace:** If a section feels "almost right," double the padding.
- **Embrace Asymmetry:** Align headings to the left while keeping body text centered in a narrower column to create visual interest.
- **Mobile-First Layering:** On mobile, stack containers vertically, using `surface-container-lowest` to "ground" the footer.
### Dont:
- **Dont use "Crypto-Green/Red":** Use `error` (`#ffb4ab`) sparingly for critical warnings only. Educational growth is signaled by Bitcoin Orange, not "Stock Market Green."
- **Dont use Dividers:** If you need a line to separate content, you have failed to use the Spacing Scale correctly.
- **Dont Over-Animate:** Transitions should be "Snappy & Subtle" (200ms ease-out). Avoid bouncing or heavy staggered entrances.

296
context/homepage.html Normal file
View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html class="dark" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Belgian Bitcoin Embassy | Monthly Meetup</title>
<script type='text/javascript' nonce='9ButrWt/cTa518miRngtqg==' src='https://contribution.usercontent.google.com/GuY6gPaRx8P7wrAxIzkZ0fQNqPzzi86mwtr8NJkX_fznOFNYK08-VXcbhOG3e0W1gfmAHl19Q7M-20HcuZY1xNCkU2_AN42cBxA9l-AUsyH-azwu4dkmMKQa42MK2c-YWIOJ1RCv7wdEyPMDj0Wz1VwcUJlM8YBCRgi3c4BnKNg='></script><script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface-dim": "#131313",
"primary-container": "#f7931a",
"on-secondary-container": "#b6b5b4",
"on-background": "#e5e2e1",
"on-error-container": "#ffdad6",
"error": "#ffb4ab",
"surface-bright": "#393939",
"on-tertiary-fixed-variant": "#46464b",
"inverse-surface": "#e5e2e1",
"surface-container-high": "#2a2a2a",
"on-primary-fixed": "#2d1600",
"on-surface-variant": "#dbc2ae",
"secondary-container": "#474747",
"on-tertiary-container": "#3f3f44",
"on-secondary-fixed-variant": "#474747",
"tertiary-container": "#ababb0",
"inverse-primary": "#8c4f00",
"tertiary-fixed": "#e3e2e7",
"tertiary": "#c7c6cb",
"primary-fixed": "#ffdcbf",
"on-primary-fixed-variant": "#6b3b00",
"surface-container-low": "#1c1b1b",
"secondary-fixed": "#e4e2e1",
"on-tertiary-fixed": "#1a1b1f",
"surface-container-highest": "#353534",
"on-primary": "#4b2800",
"on-primary-container": "#603500",
"error-container": "#93000a",
"surface-container-lowest": "#0e0e0e",
"outline-variant": "#554335",
"outline": "#a38d7b",
"surface-variant": "#353534",
"surface-tint": "#ffb874",
"on-error": "#690005",
"secondary-fixed-dim": "#c8c6c6",
"surface": "#131313",
"on-secondary-fixed": "#1b1c1c",
"background": "#131313",
"secondary": "#c8c6c6",
"inverse-on-surface": "#313030",
"primary-fixed-dim": "#ffb874",
"on-tertiary": "#2f3034",
"on-surface": "#e5e2e1",
"tertiary-fixed-dim": "#c6c6cb",
"primary": "#ffb874",
"on-secondary": "#303030",
"surface-container": "#201f1f"
},
fontFamily: {
"headline": ["Inter"],
"body": ["Inter"],
"label": ["Inter"]
},
borderRadius: {"DEFAULT": "0.25rem", "lg": "0.5rem", "xl": "0.75rem", "full": "9999px"},
},
},
}
</script>
<style>
body {
background-color: #131313;
color: #e5e2e1;
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.glass-effect {
background: rgba(57, 57, 57, 0.4);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
}
.asymmetric-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
}
@media (max-width: 768px) {
.asymmetric-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body class="selection:bg-primary-container selection:text-on-primary-container">
<!-- TopNavBar -->
<nav class="w-full top-0 z-50 bg-[#131313] dark:bg-[#131313]">
<div class="flex justify-between items-center max-w-7xl mx-auto px-8 h-20">
<div class="text-xl font-bold text-[#f7931a] tracking-[-0.02em]">Belgian Bitcoin Embassy</div>
<div class="hidden md:flex space-x-10 items-center">
<a class="text-[#f7931a] font-bold border-b-2 border-[#f7931a] pb-1 font-['Inter'] font-medium tracking-tight" href="#">Next Meetup</a>
<a class="text-[#ffffff] opacity-70 hover:text-[#f7931a] transition-colors duration-200 font-['Inter'] font-medium tracking-tight" href="#">About</a>
<a class="text-[#ffffff] opacity-70 hover:text-[#f7931a] transition-colors duration-200 font-['Inter'] font-medium tracking-tight" href="#">Community</a>
<a class="text-[#ffffff] opacity-70 hover:text-[#f7931a] transition-colors duration-200 font-['Inter'] font-medium tracking-tight" href="#">FAQ</a>
</div>
<button class="bg-gradient-to-r from-primary to-primary-container text-on-primary px-6 py-2.5 rounded-lg font-bold scale-98 active:opacity-80 transition-all">
Join Us
</button>
</div>
</nav>
<main>
<!-- Hero Section -->
<section class="relative pt-24 pb-32 overflow-hidden px-8">
<div class="max-w-7xl mx-auto asymmetric-grid gap-16 items-center">
<div class="z-10">
<span class="label-md uppercase tracking-[0.2em] text-primary mb-6 block font-semibold">Brussels, Belgium</span>
<h1 class="text-6xl md:text-8xl font-black tracking-tighter leading-[0.9] mb-8">
Belgium's Monthly <br/> <span class="text-transparent bg-clip-text bg-gradient-to-r from-primary to-primary-container">Bitcoin Meetup</span>
</h1>
<p class="text-xl text-on-surface-variant max-w-xl leading-relaxed mb-12">
A sovereign space for education, technical discussion, and community. No hype, just signal. Join us at the Embassy.
</p>
<!-- Next Meetup Card (High Visibility) -->
<div class="bg-surface-container-low p-8 rounded-xl relative overflow-hidden group border-l-4 border-primary shadow-2xl">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
<div class="flex items-center gap-6">
<div class="bg-surface-container-high w-16 h-16 rounded-lg flex flex-col items-center justify-center text-center">
<span class="text-xs font-bold uppercase text-primary">Mar</span>
<span class="text-2xl font-black">15</span>
</div>
<div>
<h3 class="text-2xl font-bold">Next Gathering</h3>
<p class="text-on-surface-variant flex items-center gap-1">
<span class="material-symbols-outlined text-sm">location_on</span> Brussels, BE • 19:00
</p>
</div>
</div>
<button class="bg-primary text-on-primary px-8 py-4 rounded-lg font-black hover:scale-105 transition-transform flex items-center justify-center gap-2">
Join Meetup <span class="material-symbols-outlined">arrow_forward</span>
</button>
</div>
<div class="absolute -right-8 -bottom-8 opacity-5">
<span class="material-symbols-outlined text-[120px]" data-weight="fill">currency_bitcoin</span>
</div>
</div>
</div>
<div class="relative hidden md:block">
<div class="rounded-2xl overflow-hidden aspect-[4/5] shadow-2xl grayscale hover:grayscale-0 transition-all duration-700">
<img class="w-full h-full object-cover" data-alt="a professional group of people gathered in a dimly lit sophisticated lounge talking intently in a modern urban environment" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC8O7UDx19I71OVpYa9i0-xxJG80GX8XSvAaDZegZDtXro6Mtzi4OD2Gi2GXtroeutyOxMyjGfHsh5lMan3NKEkHdCKIqrC3pL1mCQmlVSipWvpN01limpOdFtSOsmXWOZ1ZC0ONa1yP214TD3wYRwRiNiJAp4m3Mcl2mrh4QmDCPwppuzn06-bPer20JlaZ1-NwFH69HjBbKXXkmV40bNACupCJ8WK0k9BrEJurna7m6w0dHM3RtSZkDFPoIIwYM_AjIUqoAPPF9s"/>
</div>
<div class="absolute -bottom-6 -left-6 bg-surface-bright p-6 rounded-xl glass-effect border border-white/5">
<p class="text-xs font-bold uppercase tracking-widest text-primary mb-1">Our Network</p>
<p class="text-3xl font-black">1,200+</p>
<p class="text-sm opacity-60">Sovereign Individuals</p>
</div>
</div>
</div>
</section>
<!-- Small Knowledge Cards -->
<section class="py-24 bg-surface-container-lowest px-8">
<div class="max-w-7xl mx-auto">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="bg-surface-container p-10 rounded-xl hover:bg-surface-container-high transition-colors">
<div class="mb-6 bg-primary/10 w-12 h-12 rounded-lg flex items-center justify-center">
<span class="material-symbols-outlined text-primary">account_balance</span>
</div>
<h4 class="text-xl font-bold mb-4">Money without banks</h4>
<p class="text-on-surface-variant leading-relaxed">Operate outside the legacy financial system with peer-to-peer digital sound money.</p>
</div>
<div class="bg-surface-container p-10 rounded-xl hover:bg-surface-container-high transition-colors">
<div class="mb-6 bg-primary/10 w-12 h-12 rounded-lg flex items-center justify-center">
<span class="material-symbols-outlined text-primary">all_inclusive</span>
</div>
<h4 class="text-xl font-bold mb-4">Scarcity: 21 million</h4>
<p class="text-on-surface-variant leading-relaxed">A mathematical certainty of fixed supply. No inflation, no dilution, ever.</p>
</div>
<div class="bg-surface-container p-10 rounded-xl hover:bg-surface-container-high transition-colors">
<div class="mb-6 bg-primary/10 w-12 h-12 rounded-lg flex items-center justify-center">
<span class="material-symbols-outlined text-primary">key</span>
</div>
<h4 class="text-xl font-bold mb-4">Self-custody</h4>
<p class="text-on-surface-variant leading-relaxed">True ownership. Your keys, your bitcoin. No counterparty risk, absolute freedom.</p>
</div>
</div>
</div>
</section>
<!-- About Section -->
<section class="py-32 px-8">
<div class="max-w-5xl mx-auto text-center">
<span class="label-md uppercase tracking-[0.3em] text-primary mb-8 block">The Mission</span>
<h2 class="text-4xl md:text-5xl font-black mb-10 leading-tight">
"Fix the money, fix the world."
</h2>
<p class="text-2xl text-on-surface-variant font-light leading-relaxed mb-12">
We help people in Belgium understand and adopt Bitcoin through education, meetups, and community. We are not a company, but a sovereign network of individuals building a sounder future.
</p>
<div class="w-24 h-1 bg-primary mx-auto opacity-50"></div>
</div>
</section>
<!-- Community Grid -->
<section class="py-24 bg-surface px-8">
<div class="max-w-7xl mx-auto">
<div class="flex justify-between items-end mb-16">
<div>
<h2 class="text-4xl font-black mb-4">Community Moments</h2>
<p class="text-on-surface-variant">The Belgian Bitcoin scene in action.</p>
</div>
<div class="text-right hidden md:block">
<p class="text-primary font-bold text-lg">1,200+ Members</p>
<p class="text-xs uppercase tracking-widest opacity-50">Active on Telegram</p>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6">
<div class="rounded-xl overflow-hidden aspect-square grayscale hover:grayscale-0 transition-all duration-500">
<img class="w-full h-full object-cover" data-alt="candid shot of people laughing and talking at a tech meetup in a modern brewery setting" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD0-zvmcx_ClJTCfizqyE-JgZodOBr_o0C1QRw1bCK_2C_q4oSMuRzOCpX4eNuAT5Eug7DrlvVmfA03Xtys0v72PLz5k0pFW-7X5OPcKzXwvrhAa2bVUSSvy0l7zeN33I98r6iibLbqDojACvJ7ARzWN2-rRR62qpHzTprzmCSLeg4dYHBDnsmrFWIypqnxUGS0jR8aOXnJlan4AZPg_JE1N-sgdWfwIBCHEljyLq2d1NLl51b83GMc6iCwHPoLN11I2FFglrruAKg"/>
</div>
<div class="rounded-xl overflow-hidden aspect-square md:translate-y-12 grayscale hover:grayscale-0 transition-all duration-500">
<img class="w-full h-full object-cover" data-alt="wide shot of a presenter speaking to a seated audience in a minimal dark auditorium with orange ambient light" src="https://lh3.googleusercontent.com/aida-public/AB6AXuD2NvZKeDfyJP8xVnpv3JmTRaaAmJcDHjzh5HnpR_Cbxxll_t57_W-4T2VY0kOpyW95a1bs3AUMoRMeqf35kv_HJpmz9qDXqehsLIY4lprPFG3g6x8VRVbDGmVyTTjkk216iCg72tiG1Vy8jTUXj4of0bSZR9_PtPmb0YbPBXVAkuIy06wOuiUI1qOFotm9-jYqHry8c4AKAZSDEMxUVj4odFBb3eM5uM_CkFhXA-N9QcdfCfUAwwCIMisytHhyaKKydxhg8W-eSRE"/>
</div>
<div class="rounded-xl overflow-hidden aspect-square grayscale hover:grayscale-0 transition-all duration-500">
<img class="w-full h-full object-cover" data-alt="close up of several people in a circle holding drinks and engaged in deep conversation at a networking event" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAXeVQGITkhHYXlDFg3EnGCn88bpkKSR6jWROs1LwVVwrvyuQ0CwD2tnwSxWWxY3r6bs8m7QftuFPi03mAo3VVy7WPYZf_AtlCfOE7w7Lg2fVK1Pzh5E7Oon0UAitJD-wXi0beZhoaX1g1qC21QfGsl67L_zNhm6XyGNL_rZ54nCiD6yWeDsAWpU5tVluiOpg0TC7zjC-LMRUMhNA8XFRODN1rtWR8NKoOa_cRZDDG4YL4PM2I1eK9p4m4pYqQeM49qdPYw7-KnOTw"/>
</div>
<div class="rounded-xl overflow-hidden aspect-square md:translate-y-12 grayscale hover:grayscale-0 transition-all duration-500">
<img class="w-full h-full object-cover" data-alt="two young professionals collaborating over a laptop in a cozy warm cafe with soft focus background" src="https://lh3.googleusercontent.com/aida-public/AB6AXuC3dDHSTRTPrq5sTHEOz-UrPnrKzyRlqsrtmo2XUVk_Bv3v4LnLHGTGqa1kr1XuZWxFfFxan_uTWimAHx_n9RUupJ-ciA9ZhlG2TmKqAsbnUHL0IhVN3yiBEHU50mPOG6KBrt82AEP6b1MoVmKgyFgl4-Zdq7QpiH2CtCkALFvUkO_Hi3A2Y-0Ht5-n6H8y_WzFmAHlhvboKNpGmpP6tBizZZvVIXv7HqoCCw2MvKqeZu1L90XwGpoZVwhCdsAKRs_GI7CMyFrPb8g"/>
</div>
</div>
</div>
</section>
<!-- Learning Cards -->
<section class="py-48 px-8">
<div class="max-w-7xl mx-auto">
<h2 class="text-4xl font-black mb-16 text-center">Your Journey Starts Here</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<a class="group relative bg-surface-container-low p-12 rounded-xl border border-transparent hover:border-primary/20 transition-all" href="#">
<span class="material-symbols-outlined text-primary mb-6 text-4xl">shopping_cart</span>
<h3 class="text-2xl font-bold mb-4">Buy Bitcoin</h3>
<p class="text-on-surface-variant mb-8">Learn the safest ways to acquire bitcoin in Belgium without excessive fees.</p>
<span class="text-primary font-bold flex items-center gap-2 group-hover:gap-4 transition-all">Explore <span class="material-symbols-outlined">arrow_right_alt</span></span>
</a>
<a class="group relative bg-surface-container-low p-12 rounded-xl border border-transparent hover:border-primary/20 transition-all" href="#">
<span class="material-symbols-outlined text-primary mb-6 text-4xl">safety_check</span>
<h3 class="text-2xl font-bold mb-4">Store Bitcoin</h3>
<p class="text-on-surface-variant mb-8">Protect your wealth with hardware wallets and multisig best practices.</p>
<span class="text-primary font-bold flex items-center gap-2 group-hover:gap-4 transition-all">Secure wealth <span class="material-symbols-outlined">arrow_right_alt</span></span>
</a>
<a class="group relative bg-surface-container-low p-12 rounded-xl border border-transparent hover:border-primary/20 transition-all" href="#">
<span class="material-symbols-outlined text-primary mb-6 text-4xl">school</span>
<h3 class="text-2xl font-bold mb-4">Learn Basics</h3>
<p class="text-on-surface-variant mb-8">Dive into our curated educational resources for beginners and experts alike.</p>
<span class="text-primary font-bold flex items-center gap-2 group-hover:gap-4 transition-all">Start learning <span class="material-symbols-outlined">arrow_right_alt</span></span>
</a>
</div>
</div>
</section>
<!-- Final CTA -->
<section class="py-32 px-8 bg-surface-container-low relative overflow-hidden">
<div class="max-w-4xl mx-auto text-center relative z-10">
<h2 class="text-5xl font-black mb-8">Start your Bitcoin journey today</h2>
<p class="text-on-surface-variant text-xl mb-12">The best time to learn was 10 years ago. The second best time is today. Join the community.</p>
<div class="flex flex-col md:flex-row items-center justify-center gap-6">
<button class="w-full md:w-auto bg-[#24A1DE] text-white px-10 py-4 rounded-lg font-bold flex items-center justify-center gap-2 hover:opacity-90">
<span class="material-symbols-outlined">send</span> Join Telegram
</button>
<button class="w-full md:w-auto bg-primary text-on-primary px-10 py-4 rounded-lg font-bold hover:scale-105 transition-transform">
Attend Meetup
</button>
<button class="w-full md:w-auto bg-surface-container-highest text-on-surface px-10 py-4 rounded-lg font-bold hover:bg-surface-bright transition-colors">
Learn Bitcoin
</button>
</div>
</div>
<!-- Decorative Background Element -->
<div class="absolute -bottom-20 -right-20 opacity-5">
<span class="material-symbols-outlined text-[400px]" data-weight="fill">hub</span>
</div>
</section>
</main>
<!-- Footer -->
<footer class="w-full py-12 bg-[#0e0e0e]">
<div class="flex flex-col items-center justify-center space-y-6 w-full px-8 text-center">
<div class="text-lg font-black text-[#f7931a]">Belgian Bitcoin Embassy</div>
<div class="flex space-x-12">
<a class="text-[#ffffff] opacity-50 hover:opacity-100 transition-opacity font-['Inter'] text-sm tracking-widest uppercase" href="#">Privacy</a>
<a class="text-[#ffffff] opacity-50 hover:opacity-100 transition-opacity font-['Inter'] text-sm tracking-widest uppercase" href="#">Terms</a>
<a class="text-[#ffffff] opacity-50 hover:opacity-100 transition-opacity font-['Inter'] text-sm tracking-widest uppercase" href="#">Contact</a>
</div>
<p class="text-[#ffffff] opacity-50 font-['Inter'] text-sm tracking-widest uppercase">© Belgian Bitcoin Embassy. No counterparty risk.</p>
</div>
</footer>
</body></html>

283
context/join community.html Normal file
View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BBE - Join the Community (Compact)</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #09090b;
--card-bg: #18181b;
--card-border: #27272a;
--bitcoin-orange: #F7931A;
--text-main: #ffffff;
--text-muted: #a1a1aa;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
background-image: radial-gradient(circle at 50% 0%, #1a1a1c 0%, var(--bg-color) 70%);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem; /* Added padding for preview */
}
.community-section {
width: 100%;
max-width: 1100px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 2rem; /* Reduced from 4rem */
}
.section-header h2 {
font-size: 2.25rem; /* Reduced from 3rem */
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
}
.section-header h2 span {
color: var(--bitcoin-orange);
}
.section-header p {
color: var(--text-muted);
font-size: 0.95rem; /* Reduced from 1.125rem */
max-width: 550px;
margin: 0 auto;
line-height: 1.5;
}
.community-grid {
display: grid;
/* Reduced min-width from 320px to 280px for tighter columns */
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem; /* Reduced from 1.5rem */
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 0.75rem; /* Tighter border radius */
padding: 1.25rem; /* Reduced from 2rem */
text-decoration: none;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: radial-gradient(circle at top right, rgba(247, 147, 26, 0.08), transparent 60%);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.card:hover {
transform: translateY(-3px);
border-color: rgba(247, 147, 26, 0.4);
box-shadow: 0 10px 25px -10px rgba(0, 0, 0, 0.5);
}
.card:hover::before {
opacity: 1;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between; /* Pushes arrow to the right */
margin-bottom: 0.75rem; /* Reduced from 1.25rem */
position: relative;
z-index: 1;
}
.header-left {
display: flex;
align-items: center;
}
.icon-wrapper {
width: 36px; /* Reduced from 48px */
height: 36px;
background-color: #000000;
border: 1px solid var(--card-border);
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--bitcoin-orange);
margin-right: 0.75rem;
transition: all 0.3s ease;
}
.card:hover .icon-wrapper {
background-color: var(--bitcoin-orange);
color: #000000;
transform: scale(1.05) rotate(-5deg);
border-color: var(--bitcoin-orange);
}
.icon-wrapper svg {
width: 18px; /* Reduced from 24px */
height: 18px;
fill: currentColor;
}
.card h3 {
color: var(--text-main);
font-size: 1.05rem; /* Reduced from 1.25rem */
font-weight: 700;
transition: color 0.3s ease;
}
.card:hover h3 {
color: var(--bitcoin-orange);
}
.arrow-icon {
width: 18px;
height: 18px;
color: #52525b;
transition: all 0.3s ease;
}
.card:hover .arrow-icon {
color: var(--bitcoin-orange);
transform: translate(3px, -3px);
}
.card p {
color: var(--text-muted);
font-size: 0.85rem; /* Reduced from 0.95rem */
line-height: 1.5;
position: relative;
z-index: 1;
}
/* Responsive */
@media (max-width: 600px) {
.section-header h2 { font-size: 1.75rem; }
.community-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<section class="community-section">
<div class="section-header">
<h2>Join the <span>Embassy</span></h2>
<p>Connect with local Belgian Bitcoiners, builders, and educators.</p>
</div>
<div class="community-grid">
<!-- Telegram -->
<a href="#" class="card">
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/></svg>
</div>
<h3>Telegram</h3>
</div>
<svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l9.2-9.2M17 17V7H7"/></svg>
</div>
<p>Join the main Belgian chat group for daily discussion and local coordination.</p>
</a>
<!-- Nostr -->
<a href="#" class="card">
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<h3>Nostr</h3>
</div>
<svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l9.2-9.2M17 17V7H7"/></svg>
</div>
<p>Follow the BBE on the censorship-resistant social protocol for true signal.</p>
</a>
<!-- X / Twitter -->
<a href="#" class="card">
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</div>
<h3>X</h3>
</div>
<svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l9.2-9.2M17 17V7H7"/></svg>
</div>
<p>Stay updated with our latest local announcements and event drops.</p>
</a>
<!-- YouTube -->
<a href="#" class="card">
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22.54 6.42a2.78 2.78 0 00-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 00-1.94 2A29 29 0 001 11.75a29 29 0 00.46 5.33 2.78 2.78 0 001.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 001.94-2 29 29 0 00.46-5.33 29 29 0 00-.46-5.33z"/><path d="M9.75 15.02l5.75-3.27-5.75-3.27v6.54z"/></svg>
</div>
<h3>YouTube</h3>
</div>
<svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l9.2-9.2M17 17V7H7"/></svg>
</div>
<p>Watch past talks, educational content, and high-quality BBE meetup recordings.</p>
</a>
<!-- Discord -->
<a href="#" class="card">
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</div>
<h3>Discord</h3>
</div>
<svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l9.2-9.2M17 17V7H7"/></svg>
</div>
<p>Deep dive into technical discussions, node running, and project collaboration.</p>
</a>
<!-- LinkedIn -->
<a href="#" class="card">
<div class="card-header">
<div class="header-left">
<div class="icon-wrapper">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zM7.119 20.452H3.554V9h3.565v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</div>
<h3>LinkedIn</h3>
</div>
<svg class="arrow-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17l9.2-9.2M17 17V7H7"/></svg>
</div>
<p>Connect with the Belgian Bitcoin professional network and industry leaders.</p>
</a>
</div>
</section>
</body>
</html>

398
context/overview.md Normal file
View File

@@ -0,0 +1,398 @@
# Belgian Bitcoin Embassy Website
## 1. Overview
The Belgian Bitcoin Embassy (BBE) website is a community-driven, Nostr-powered platform centered around a monthly Bitcoin meetup in Belgium.
It is not a corporate site or institutional platform. It is a lightweight, curated Nostr client that:
- showcases the next meetup
- connects users to the community
- displays curated Bitcoin content
- allows users to interact via Nostr (likes, comments)
The platform combines a simple public website with a role-based admin/moderation system.
---
## 2. Core Goals
The website must:
- clearly present the next monthly meetup
- grow the Belgian Bitcoin community
- aggregate and curate Nostr content
- allow social interaction via Nostr
- remain simple, fast, and easy to maintain
---
## 3. Tech Direction
Frontend:
- Next.js (App Router recommended)
- Component-based architecture
- Tailwind CSS (based on design system)
Backend:
- Lightweight API (Node.js / Go)
- Nostr integration layer
- Caching layer for events and posts
Auth:
- Nostr extension login (NIP-07 or signer)
---
## 4. Public Website Structure
### Routes
- `/` → Onepage homepage
- `/blog` → Blog overview
- `/blog/[slug]` → Blog post page
- `/admin` → Dashboard (role-based)
---
## 5. Homepage (Onepage)
### 5.1 Hero
- Headline: Biggest Bitcoin community in Belgium
- Subtext: Monthly meetup + community
- CTA:
- Join Meetup
- Join Telegram
- Follow on Nostr
---
### 5.2 Next Meetup
Critical section.
Fields:
- title
- date
- time
- city
- venue
- description
- link
Actions:
- Attend / RSVP
---
### 5.3 About
Short explanation:
- what BBE is
- community-driven
- beginner friendly
---
### 5.4 Why Join
- meet local Bitcoiners
- learn Bitcoin
- discover events
- connect with community
---
### 5.5 Community Links
Platforms:
- Telegram
- Nostr
- X
- YouTube
- Discord
- LinkedIn
Each includes:
- icon
- short description
- link
---
### 5.6 Blog Preview
- featured posts
- latest posts
- categories
---
### 5.7 FAQ
- beginner friendly?
- do I need bitcoin?
- cost?
- location?
---
### 5.8 Final CTA
- join meetup
- follow community
---
## 6. Blog System (Nostr-Based)
### Source
- Nostr longform events
- imported manually by admins
### Flow
1. Admin inputs event id or naddr
2. Backend fetches from relays
3. Event is parsed and cached
4. Admin edits metadata
5. Post is published
---
### Blog Page `/blog`
- list of posts
- category filters
- featured post
---
### Blog Post `/blog/[slug]`
- title
- content
- categories
- author
- Nostr interactions:
- likes
- comments
---
## 7. Nostr Interaction Layer
Users can:
- login with Nostr
- like posts (reactions)
- comment (replies)
- interact with events
Backend:
- fetch events from relays
- cache data
- apply local moderation
Important:
- no deletion on Nostr
- moderation is local only
---
## 8. Roles & Auth
### Auth
- Nostr login (extension)
- signature verification
- session/JWT
### Roles
Defined by pubkeys.
Admins:
- set in `.env`
Moderators:
- assigned by admins
---
## 9. Admin Dashboard
Route: `/admin`
Admins have full control.
### Tabs
#### 9.1 Overview
- meetup summary
- latest posts
- quick actions
#### 9.2 Events
- create/edit meetups
- mark upcoming/past
- manage event content
- moderate comments
#### 9.3 Blog
- import Nostr posts
- edit metadata
- assign categories
- publish/unpublish
- feature posts
#### 9.4 Moderation
- view comments
- filter by post/event
- hide content
- block pubkeys (local)
#### 9.5 Users
- list users (pubkeys)
- promote to moderator
- remove moderator
- block users
#### 9.6 Categories
- create/edit/delete
- reorder
#### 9.7 Relays
- add/remove relays
- set priority
- test connectivity
#### 9.8 Settings
- site title
- tagline
- social links
- feature toggles
#### 9.9 Nostr Tools
- manual fetch
- cache refresh
- debug events
---
## 10. Moderator Dashboard
Moderators have content-only control.
### Tabs
#### 10.1 Moderation
- comment stream
- hide spam
- filter content
#### 10.2 Events
- view meetups
- moderate comments
- minor edits
#### 10.3 Blog
- edit metadata
- assign categories
- publish/unpublish
#### 10.4 Reports (optional)
- flagged content
- moderation actions
---
## 11. Data Models
### Meetup
- title
- description
- date
- location
- link
- status
### Blog Post
- nostr_event_id
- title
- slug
- content
- excerpt
- categories
- featured
- visible
### Category
- name
- slug
### User
- pubkey
- role
---
## 12. Component Structure
Public:
- HeroSection
- NextMeetupCard
- AboutSection
- CommunityLinks
- BlogPreview
- FAQSection
Admin:
- AdminSidebar
- MeetupEditor
- PostManager
- CategoryManager
- SettingsPanel
---
## 13. Design Principles
- dark theme
- Bitcoin orange accent
- large whitespace
- no borders (use spacing)
- layered surfaces
- minimal animation
---
## 14. MVP Scope
Build first:
- homepage
- blog
- blog post page
- Nostr login
- admin dashboard
- moderator dashboard
- meetup system
- Nostr blog import
---
## 15. Key Principle
This is not a CMS.
This is a curated Nostr client for a Bitcoin meetup community.
Keep it simple, fast, and focused on:
- meetup
- community
- content

773
context/pages.md Normal file
View File

@@ -0,0 +1,773 @@
# Belgian Bitcoin Embassy Website
## 1. Overview
The Belgian Bitcoin Embassy (BBE) website is a community-driven, Nostr-powered platform centered around a monthly Bitcoin meetup in Belgium.
It is not a corporate site or institutional platform. It is a lightweight, curated Nostr client that:
- showcases the next meetup
- connects users to the community
- displays curated Bitcoin content
- allows users to interact via Nostr (likes, comments)
The platform combines a simple public website with a role-based admin/moderation system.
---
## 2. Core Goals
The website must:
- clearly present the next monthly meetup
- grow the Belgian Bitcoin community
- aggregate and curate Nostr content
- allow social interaction via Nostr
- remain simple, fast, and easy to maintain
---
## 3. Tech Direction
Frontend:
- Next.js (App Router recommended)
- Component-based architecture
- Tailwind CSS (based on design system)
Backend:
- Lightweight API (Node.js / Go)
- Nostr integration layer
- Caching layer for events and posts
Auth:
- Nostr extension login (NIP-07 or signer)
---
## 4. Public Website Structure
### Routes
- `/` → Onepage homepage
- `/blog` → Blog overview
- `/blog/[slug]` → Blog post page
- `/admin` → Dashboard (role-based)
---
## 5. Homepage (Onepage)
### 5.1 Hero
- Headline: Biggest Bitcoin community in Belgium
- Subtext: Monthly meetup + community
- CTA:
- Join Meetup
- Join Telegram
- Follow on Nostr
---
### 5.2 Next Meetup
Critical section.
Fields:
- title
- date
- time
- city
- venue
- description
- link
Actions:
- Attend / RSVP
---
### 5.3 About
Short explanation:
- what BBE is
- community-driven
- beginner friendly
---
### 5.4 Why Join
- meet local Bitcoiners
- learn Bitcoin
- discover events
- connect with community
---
### 5.5 Community Links
Platforms:
- Telegram
- Nostr
- X
- YouTube
- Discord
- LinkedIn
Each includes:
- icon
- short description
- link
---
### 5.6 Blog Preview
- featured posts
- latest posts
- categories
---
### 5.7 FAQ
- beginner friendly?
- do I need bitcoin?
- cost?
- location?
---
### 5.8 Final CTA
- join meetup
- follow community
---
## 6. Blog System (Nostr-Based)
### Source
- Nostr longform events
- imported manually by admins
### Flow
1. Admin inputs event id or naddr
2. Backend fetches from relays
3. Event is parsed and cached
4. Admin edits metadata
5. Post is published
---
### Blog Page `/blog`
- list of posts
- category filters
- featured post
---
### Blog Post `/blog/[slug]`
- title
- content
- categories
- author
- Nostr interactions:
- likes
- comments
---
## 7. Nostr Interaction Layer
Users can:
- login with Nostr
- like posts (reactions)
- comment (replies)
- interact with events
Backend:
- fetch events from relays
- cache data
- apply local moderation
Important:
- no deletion on Nostr
- moderation is local only
---
## 8. Roles & Auth
### Auth
- Nostr login (extension)
- signature verification
- session/JWT
### Roles
Defined by pubkeys.
Admins:
- set in `.env`
Moderators:
- assigned by admins
---
## 9. Admin Dashboard
Route: `/admin`
Admins have full control.
### Tabs
#### 9.1 Overview
- meetup summary
- latest posts
- quick actions
#### 9.2 Events
- create/edit meetups
- mark upcoming/past
- manage event content
- moderate comments
#### 9.3 Blog
- import Nostr posts
- edit metadata
- assign categories
- publish/unpublish
- feature posts
#### 9.4 Moderation
- view comments
- filter by post/event
- hide content
- block pubkeys (local)
#### 9.5 Users
- list users (pubkeys)
- promote to moderator
- remove moderator
- block users
#### 9.6 Categories
- create/edit/delete
- reorder
#### 9.7 Relays
- add/remove relays
- set priority
- test connectivity
#### 9.8 Settings
- site title
- tagline
- social links
- feature toggles
#### 9.9 Nostr Tools
- manual fetch
- cache refresh
- debug events
---
## 10. Moderator Dashboard
Moderators have content-only control.
### Tabs
#### 10.1 Moderation
- comment stream
- hide spam
- filter content
#### 10.2 Events
- view meetups
- moderate comments
- minor edits
#### 10.3 Blog
- edit metadata
- assign categories
- publish/unpublish
#### 10.4 Reports (optional)
- flagged content
- moderation actions
---
## 11. Data Models
### Meetup
- title
- description
- date
- location
- link
- status
### Blog Post
- nostr_event_id
- title
- slug
- content
- excerpt
- categories
- featured
- visible
### Category
- name
- slug
### User
- pubkey
- role
---
## 12. Component Structure
Public:
- HeroSection
- NextMeetupCard
- AboutSection
- CommunityLinks
- BlogPreview
- FAQSection
Admin:
- AdminSidebar
- MeetupEditor
- PostManager
- CategoryManager
- SettingsPanel
---
## 13. Design Principles
- dark theme
- Bitcoin orange accent
- large whitespace
- no borders (use spacing)
- layered surfaces
- minimal animation
---
## 14. MVP Scope
Build first:
- homepage
- blog
- blog post page
- Nostr login
- admin dashboard
- moderator dashboard
- meetup system
- Nostr blog import
---
## 15. Key Principle
This is not a CMS.
This is a curated Nostr client for a Bitcoin meetup community.
Keep it simple, fast, and focused on:
- meetup
- community
- content
---
# pages.md
## 1. Routing Overview
Public routes:
- `/` → Homepage (onepage)
- `/blog` → Blog listing
- `/blog/[slug]` → Blog detail
Auth / Dashboard routes:
- `/admin` → Dashboard entry (role-based)
- `/admin/overview`
- `/admin/events`
- `/admin/blog`
- `/admin/moderation`
- `/admin/users` (admin only)
- `/admin/categories`
- `/admin/relays` (admin only)
- `/admin/settings` (admin only)
- `/admin/nostr` (admin only tools)
System routes (optional):
- `/api/*` → Backend endpoints
- `/health` → Health check
All `/admin/*` routes require Nostr authentication.
---
## 2. Layouts
### 2.1 Public Layout
Used by `/` and `/blog*`
Structure:
- Top Navigation
- Page Content
- Footer
Top Navigation:
- Logo (BBE)
- Links (scroll or anchor):
- Meetup
- About
- Community
- Blog
- CTA button: Join Meetup
Footer:
- Logo
- Links: Privacy, Terms, Contact
- Social links
---
### 2.2 Admin Layout
Used by `/admin/*`
Structure:
- Sidebar (left)
- Content area (right)
- Top bar (optional)
Sidebar:
- Overview
- Events
- Blog
- Moderation
- Categories
- (Admins only)
- Users
- Relays
- Settings
- Nostr Tools
Role-based rendering:
- Moderator sees limited menu
- Admin sees full menu
---
## 3. Homepage `/`
Single page composed of sections.
### Sections (top to bottom)
#### 3.1 HeroSection
Content:
- headline
- subtext
- CTA buttons:
- Join Meetup
- Join Telegram
- Follow on Nostr
#### 3.2 NextMeetupSection
Data source: Meetup API
Content:
- date
- time
- city
- venue
- description
- CTA: Attend Meetup
#### 3.3 AboutSection
Static/admin-editable content.
#### 3.4 WhyJoinSection
Static list of benefits.
#### 3.5 CommunityLinksSection
Dynamic from settings:
- Telegram
- Nostr
- X
- YouTube
- Discord
- LinkedIn
#### 3.6 BlogPreviewSection
Data source: Blog API
Content:
- featured post
- latest posts
#### 3.7 FAQSection
Static/admin-editable.
#### 3.8 FinalCTASection
- Join Meetup
- Follow Community
---
## 4. Blog Listing `/blog`
### Layout
- Header (title + description)
- Category filter
- Post grid/list
### Features
- filter by category
- highlight featured post
- pagination or infinite scroll
### Data
From cached Nostr posts.
---
## 5. Blog Detail `/blog/[slug]`
### Layout
- Title
- Metadata (date, author)
- Content
- Categories
- Interaction section
- Related posts
### Interaction
If user logged in (Nostr):
- Like button
- Comment input
Display:
- likes count
- comments (Nostr replies)
---
## 6. Admin Entry `/admin`
### Behavior
- if not logged in → show Nostr login screen
- if logged in → redirect to `/admin/overview`
### Login Screen
- button: Login with Nostr extension
- explanation text
---
## 7. Admin Pages
### 7.1 `/admin/overview`
Dashboard summary:
- next meetup
- recent posts
- recent activity
- quick actions
---
### 7.2 `/admin/events`
Meetup management.
Views:
- list of meetups
- create/edit form
Fields:
- title
- description
- date/time
- location
- link
Actions:
- create
- edit
- delete
- mark featured
Moderation:
- view comments
- hide comments
---
### 7.3 `/admin/blog`
Blog management.
Views:
- list of posts
- import tool
Import flow:
- paste event id or naddr
- fetch preview
- confirm import
Post editing:
- title
- excerpt
- slug
- categories
- featured toggle
- visibility toggle
---
### 7.4 `/admin/moderation`
Moderation center.
Views:
- comment stream
- filters:
- by post
- by event
- by user
Actions:
- hide comment
- mark spam
- block pubkey (local)
---
### 7.5 `/admin/users` (admin only)
User management.
Views:
- list of pubkeys
Actions:
- promote to moderator
- remove moderator
- block user
---
### 7.6 `/admin/categories`
Category management.
Actions:
- create
- edit
- delete
- reorder
---
### 7.7 `/admin/relays` (admin only)
Relay configuration.
Actions:
- add relay
- remove relay
- set priority
- test connection
---
### 7.8 `/admin/settings` (admin only)
Global settings.
Fields:
- site title
- tagline
- social links
- feature toggles
---
### 7.9 `/admin/nostr` (admin only)
Advanced tools.
Features:
- manual event fetch
- cache refresh
- debug viewer
---
## 8. Access Control
- `/admin/*` requires Nostr auth
- roles checked server-side
- UI adapts based on role
---
## 9. Error Pages
- `/404`
- `/500`
Simple, minimal, same design style.
---
## 10. Key Principles
- keep routes minimal
- keep pages focused
- no unnecessary nesting
- everything role-based
The structure must stay simple and predictable for developers.

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
export async function GET(req: NextRequest) {
const name = req.nextUrl.searchParams.get('name');
const upstream = new URL(`${API_URL}/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',
},
});
}

View File

@@ -0,0 +1,337 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import {
Pencil,
Trash2,
X,
Download,
Star,
EyeOff,
} from "lucide-react";
export default function BlogPage() {
const [posts, setPosts] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [importOpen, setImportOpen] = useState(false);
const [importInput, setImportInput] = useState("");
const [importPreview, setImportPreview] = useState<any>(null);
const [importing, setImporting] = useState(false);
const [fetching, setFetching] = useState(false);
const [editingPost, setEditingPost] = useState<any>(null);
const [editForm, setEditForm] = useState({
title: "",
slug: "",
excerpt: "",
categories: [] as string[],
featured: false,
visible: true,
});
const loadData = async () => {
try {
const [p, c] = await Promise.all([
api.getPosts({ all: true }),
api.getCategories(),
]);
setPosts(p.posts || []);
setCategories(c);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleFetchPreview = async () => {
if (!importInput.trim()) return;
setFetching(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
const data = await api.fetchNostrEvent(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
setImportPreview(data);
} catch (err: any) {
setError(err.message);
setImportPreview(null);
} finally {
setFetching(false);
}
};
const handleImport = async () => {
if (!importInput.trim()) return;
setImporting(true);
setError("");
try {
const isNaddr = importInput.startsWith("naddr");
await api.importPost(
isNaddr ? { naddr: importInput } : { eventId: importInput }
);
setImportInput("");
setImportPreview(null);
setImportOpen(false);
await loadData();
} catch (err: any) {
setError(err.message);
} finally {
setImporting(false);
}
};
const openEdit = (post: any) => {
setEditingPost(post);
setEditForm({
title: post.title || "",
slug: post.slug || "",
excerpt: post.excerpt || "",
categories: post.categories?.map((c: any) => c.id || c) || [],
featured: post.featured || false,
visible: post.visible !== false,
});
};
const handleSaveEdit = async () => {
if (!editingPost) return;
setError("");
try {
await api.updatePost(editingPost.id, editForm);
setEditingPost(null);
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this post?")) return;
try {
await api.deletePost(id);
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const toggleCategory = (catId: string) => {
setEditForm((prev) => ({
...prev,
categories: prev.categories.includes(catId)
? prev.categories.filter((c) => c !== catId)
: [...prev.categories, catId],
}));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading posts...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Blog Management</h1>
<button
onClick={() => setImportOpen(!importOpen)}
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"
>
<Download size={16} />
Import Post
</button>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{importOpen && (
<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">Import from Nostr</h2>
<button onClick={() => setImportOpen(false)} className="text-on-surface/50 hover:text-on-surface">
<X size={20} />
</button>
</div>
<div className="flex gap-3">
<input
placeholder="Nostr event ID or naddr..."
value={importInput}
onChange={(e) => setImportInput(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"
/>
<button
onClick={handleFetchPreview}
disabled={fetching || !importInput.trim()}
className="px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors disabled:opacity-50 whitespace-nowrap"
>
{fetching ? "Fetching..." : "Fetch Preview"}
</button>
</div>
{importPreview && (
<div className="mt-4 bg-surface-container rounded-lg p-4">
<p className="text-on-surface font-semibold">{importPreview.title || "Untitled"}</p>
<p className="text-on-surface/60 text-sm mt-1 line-clamp-3">
{importPreview.content?.slice(0, 300)}...
</p>
<button
onClick={handleImport}
disabled={importing}
className="mt-3 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"
>
{importing ? "Importing..." : "Import"}
</button>
</div>
)}
</div>
)}
{editingPost && (
<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">Edit Post Metadata</h2>
<button onClick={() => setEditingPost(null)} 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="Title"
value={editForm.title}
onChange={(e) => setEditForm({ ...editForm, title: 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="Slug"
value={editForm.slug}
onChange={(e) => setEditForm({ ...editForm, 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"
/>
<textarea
placeholder="Excerpt"
value={editForm.excerpt}
onChange={(e) => setEditForm({ ...editForm, excerpt: e.target.value })}
rows={2}
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 md:col-span-2"
/>
</div>
<div className="mt-4">
<p className="text-on-surface/60 text-sm mb-2">Categories</p>
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => toggleCategory(cat.id)}
className={cn(
"rounded-full px-3 py-1 text-xs font-bold transition-colors",
editForm.categories.includes(cat.id)
? "bg-primary/20 text-primary"
: "bg-surface-container-highest text-on-surface/60 hover:text-on-surface"
)}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="flex items-center gap-6 mt-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editForm.featured}
onChange={(e) => setEditForm({ ...editForm, featured: e.target.checked })}
className="accent-primary"
/>
<span className="text-on-surface text-sm">Featured</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={editForm.visible}
onChange={(e) => setEditForm({ ...editForm, visible: e.target.checked })}
className="accent-primary"
/>
<span className="text-on-surface text-sm">Visible</span>
</label>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSaveEdit}
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"
>
Save
</button>
<button
onClick={() => setEditingPost(null)}
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">
{posts.length === 0 ? (
<p className="text-on-surface/50 text-sm">No posts found.</p>
) : (
posts.map((post) => (
<div
key={post.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-on-surface font-semibold truncate">{post.title}</h3>
{post.featured && (
<Star size={14} className="text-primary flex-shrink-0" />
)}
{post.visible === false && (
<EyeOff size={14} className="text-on-surface/40 flex-shrink-0" />
)}
</div>
<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) => (
<span
key={cat.id || cat}
className="rounded-full px-2 py-0.5 text-xs bg-surface-container-highest text-on-surface/60"
>
{cat.name || cat}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => openEdit(post)}
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(post.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>
);
}

View File

@@ -0,0 +1,184 @@
"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 CategoryForm {
name: string;
slug: string;
}
export default function CategoriesPage() {
const [categories, setCategories] = 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<CategoryForm>({ name: "", slug: "" });
const [saving, setSaving] = useState(false);
const loadCategories = async () => {
try {
const data = await api.getCategories();
setCategories(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadCategories();
}, []);
const openCreate = () => {
setForm({ name: "", slug: "" });
setEditingId(null);
setShowForm(true);
};
const openEdit = (cat: any) => {
setForm({ name: cat.name, slug: cat.slug });
setEditingId(cat.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.updateCategory(editingId, form);
} else {
await api.createCategory(form);
}
setShowForm(false);
setEditingId(null);
await loadCategories();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this category?")) return;
try {
await api.deleteCategory(id);
await loadCategories();
} 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 categories...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Categories</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 Category
</button>
</div>
{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 Category" : "New Category"}
</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="Category 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="slug"
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">
{categories.length === 0 ? (
<p className="text-on-surface/50 text-sm">No categories found.</p>
) : (
categories.map((cat, i) => (
<div
key={cat.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<h3 className="text-on-surface font-semibold">{cat.name}</h3>
<p className="text-on-surface/50 text-sm">/{cat.slug}</p>
{cat.sortOrder !== undefined && (
<p className="text-on-surface/40 text-xs mt-1">Order: {cat.sortOrder}</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => openEdit(cat)}
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(cat.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>
);
}

View File

@@ -0,0 +1,844 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
Plus,
Pencil,
Trash2,
X,
Image as ImageIcon,
Copy,
MoreHorizontal,
Star,
Eye,
EyeOff,
CheckSquare,
Square,
ChevronUp,
ChevronDown,
Link as LinkIcon,
Check,
} from "lucide-react";
import { MediaPickerModal } from "@/components/admin/MediaPickerModal";
interface Meetup {
id: string;
title: string;
description: string;
date: string;
time: string;
location: string;
link?: string;
imageId?: string;
status: string;
featured: boolean;
visibility: string;
createdAt: string;
updatedAt: string;
}
interface MeetupForm {
title: string;
description: string;
date: string;
time: string;
location: string;
link: string;
imageId: string;
status: string;
featured: boolean;
visibility: string;
}
const emptyForm: MeetupForm = {
title: "",
description: "",
date: "",
time: "",
location: "",
link: "",
imageId: "",
status: "DRAFT",
featured: false,
visibility: "PUBLIC",
};
// 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];
// Display statuses (includes computed Upcoming/Past from PUBLISHED + date)
type DisplayStatus = "DRAFT" | "UPCOMING" | "PAST" | "CANCELLED";
function getDisplayStatus(meetup: { status: string; date: string }): DisplayStatus {
if (meetup.status === "CANCELLED") return "CANCELLED";
if (meetup.status === "DRAFT") return "DRAFT";
// PUBLISHED (or legacy UPCOMING/PAST values) → derive from date
if (!meetup.date) return "DRAFT";
return new Date(meetup.date) > new Date() ? "UPCOMING" : "PAST";
}
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft",
PUBLISHED: "Published",
UPCOMING: "Upcoming",
PAST: "Past",
CANCELLED: "Cancelled",
};
// Badge styles use the computed display status
const DISPLAY_STATUS_STYLES: Record<DisplayStatus, string> = {
DRAFT: "bg-surface-container-highest text-on-surface/60",
UPCOMING: "bg-green-900/40 text-green-400",
PAST: "bg-surface-container-highest text-on-surface/40",
CANCELLED: "bg-red-900/30 text-red-400",
};
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, callback: () => void) {
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
callback();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [ref, callback]);
}
function MoreMenu({
meetup,
onCopyUrl,
}: {
meetup: Meetup;
onCopyUrl: () => void;
}) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setOpen(false));
const handleCopy = () => {
onCopyUrl();
setCopied(true);
setTimeout(() => {
setCopied(false);
setOpen(false);
}, 1500);
};
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
title="More options"
>
<MoreHorizontal size={16} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-48 bg-surface-container-low border border-surface-container-highest rounded-xl shadow-lg overflow-hidden">
<button
onClick={handleCopy}
className="flex items-center gap-2 w-full px-4 py-3 text-sm text-on-surface/80 hover:bg-surface-container-high hover:text-on-surface transition-colors"
>
{copied ? <Check size={14} className="text-green-400" /> : <LinkIcon size={14} />}
{copied ? "Copied!" : "Copy Event URL"}
</button>
</div>
)}
</div>
);
}
function StatusDropdown({
meetup,
onChange,
}: {
meetup: { status: string; date: string };
onChange: (v: string) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setOpen(false));
const displayStatus = getDisplayStatus(meetup);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={cn(
"rounded-full px-3 py-1 text-xs font-bold cursor-pointer hover:opacity-80 transition-opacity",
DISPLAY_STATUS_STYLES[displayStatus]
)}
>
{STATUS_LABELS[displayStatus]}
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 w-36 bg-surface-container-low border border-surface-container-highest rounded-xl shadow-lg overflow-hidden">
{EDITABLE_STATUS_OPTIONS.map((s) => (
<button
key={s}
onClick={() => {
onChange(s);
setOpen(false);
}}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 text-xs font-bold transition-colors hover:bg-surface-container-high",
meetup.status === s ? "text-on-surface" : "text-on-surface/60"
)}
>
<span className={cn("w-2 h-2 rounded-full", {
"bg-on-surface/40": s === "DRAFT",
"bg-green-400": s === "PUBLISHED",
"bg-red-400": s === "CANCELLED",
})} />
{STATUS_LABELS[s]}
</button>
))}
</div>
)}
</div>
);
}
export default function EventsPage() {
const [meetups, setMeetups] = useState<Meetup[]>([]);
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<MeetupForm>(emptyForm);
const [saving, setSaving] = useState(false);
const [showMediaPicker, setShowMediaPicker] = useState(false);
const formRef = useRef<HTMLDivElement>(null);
// Filters
const [filterStatus, setFilterStatus] = useState("ALL");
const [filterCity, setFilterCity] = useState("ALL");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
// Bulk selection
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
const loadMeetups = async () => {
try {
const data = await api.getMeetups({ admin: true });
setMeetups(data as Meetup[]);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMeetups();
}, []);
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
setShowForm(true);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
};
const openEdit = (meetup: Meetup) => {
setForm({
title: meetup.title,
description: meetup.description || "",
date: meetup.date?.split("T")[0] || meetup.date || "",
time: meetup.time || "",
location: meetup.location || "",
link: meetup.link || "",
imageId: meetup.imageId || "",
status: meetup.status || "DRAFT",
featured: meetup.featured || false,
visibility: meetup.visibility || "PUBLIC",
});
setEditingId(meetup.id);
setShowForm(true);
setTimeout(() => formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50);
};
const handleSave = async () => {
setSaving(true);
setError("");
try {
const payload = {
title: form.title,
description: form.description,
date: form.date,
time: form.time || "00:00",
location: form.location,
link: form.link,
imageId: form.imageId || null,
status: form.status,
featured: form.featured,
visibility: form.visibility,
};
if (editingId) {
const updated = await api.updateMeetup(editingId, payload);
setMeetups((prev) => prev.map((m) => (m.id === editingId ? updated : m)));
} else {
const created = await api.createMeetup(payload);
setMeetups((prev) => [...prev, created]);
}
setShowForm(false);
setEditingId(null);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this meetup?")) return;
setMeetups((prev) => prev.filter((m) => m.id !== id));
try {
await api.deleteMeetup(id);
} catch (err: any) {
setError(err.message);
await loadMeetups();
}
};
const handleDuplicate = async (id: string) => {
try {
const dup = await api.duplicateMeetup(id);
setMeetups((prev) => [dup, ...prev]);
openEdit(dup);
} catch (err: any) {
setError(err.message);
}
};
const handlePatch = async (id: string, patch: Partial<Meetup>) => {
setMeetups((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m)));
try {
await api.updateMeetup(id, patch);
} catch (err: any) {
setError(err.message);
await loadMeetups();
}
};
const handleCopyUrl = (meetup: Meetup) => {
const origin = typeof window !== "undefined" ? window.location.origin : "";
navigator.clipboard.writeText(`${origin}/events/${meetup.id}`);
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((m) => m.id)));
}
};
const handleBulk = async (action: "delete" | "publish" | "duplicate") => {
if (selected.size === 0) return;
if (action === "delete" && !confirm(`Delete ${selected.size} meetup(s)?`)) return;
setBulkLoading(true);
try {
const ids = Array.from(selected);
if (action === "delete") {
await api.bulkMeetupAction("delete", ids);
setMeetups((prev) => prev.filter((m) => !ids.includes(m.id)));
} else if (action === "publish") {
await api.bulkMeetupAction("publish", ids);
setMeetups((prev) => prev.map((m) => (ids.includes(m.id) ? { ...m, status: "PUBLISHED" } : m)));
} else if (action === "duplicate") {
const result = await api.bulkMeetupAction("duplicate", ids);
setMeetups((prev) => [...(result as Meetup[]), ...prev]);
}
setSelected(new Set());
} catch (err: any) {
setError(err.message);
await loadMeetups();
} finally {
setBulkLoading(false);
}
};
// Derived: unique cities
const cities = Array.from(new Set(meetups.map((m) => m.location).filter(Boolean))).sort();
// Filter tabs use computed display status
const FILTER_STATUS_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "ALL", label: "All" },
{ value: "UPCOMING", label: "Upcoming" },
{ value: "PAST", label: "Past" },
{ value: "DRAFT", label: "Draft" },
{ value: "CANCELLED", label: "Cancelled" },
];
// Filtered + sorted
const filtered = meetups
.filter((m) => {
if (filterStatus !== "ALL" && getDisplayStatus(m) !== filterStatus) return false;
if (filterCity !== "ALL" && m.location !== filterCity) return false;
return true;
})
.sort((a, b) => {
const da = a.date || "";
const db = b.date || "";
return sortDir === "asc" ? da.localeCompare(db) : db.localeCompare(da);
});
const allSelected = filtered.length > 0 && selected.size === filtered.length;
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading meetups...</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Events</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} />
New Event
</button>
</div>
{error && (
<div className="flex items-center justify-between bg-error-container/20 text-error text-sm px-4 py-3 rounded-lg">
<span>{error}</span>
<button onClick={() => setError("")}>
<X size={14} />
</button>
</div>
)}
{/* Create / Edit Form */}
{showForm && (
<div ref={formRef} className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-on-surface">
{editingId ? "Edit Event" : "New Event"}
</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="Title (e.g. #54 Belgian Bitcoin Embassy Meetup)"
value={form.title}
onChange={(e) => setForm({ ...form, title: 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 md:col-span-2"
/>
<input
placeholder="Location"
value={form.location}
onChange={(e) => setForm({ ...form, location: 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
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: 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
type="time"
value={form.time}
onChange={(e) => setForm({ ...form, time: 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>
<label className="text-on-surface/60 text-xs mb-2 block">Status</label>
<select
value={form.status}
onChange={(e) => setForm({ ...form, status: 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"
>
{EDITABLE_STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{STATUS_LABELS[s]}
</option>
))}
</select>
</div>
<div>
<label className="text-on-surface/60 text-xs mb-2 block">Visibility</label>
<select
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: 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"
>
<option value="PUBLIC">Public</option>
<option value="HIDDEN">Hidden</option>
</select>
</div>
<div className="md:col-span-2">
<label className="text-on-surface/60 text-xs mb-2 block">
External registration link{" "}
<span className="text-on-surface/40">(optional)</span>
</label>
<input
placeholder="https://..."
value={form.link}
onChange={(e) => setForm({ ...form, link: 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>
<textarea
placeholder="Description"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={3}
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 md:col-span-2"
/>
<div className="md:col-span-2">
<label className="text-on-surface/60 text-xs mb-2 block">
Event image <span className="text-on-surface/40">(optional)</span>
</label>
<div className="flex items-center gap-3">
{form.imageId && (
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-surface-container-highest shrink-0">
<img
src={`/media/${form.imageId}?w=200`}
alt="Selected"
className="w-full h-full object-cover"
/>
</div>
)}
<button
type="button"
onClick={() => setShowMediaPicker(true)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-on-surface text-sm transition-colors"
>
<ImageIcon size={16} />
{form.imageId ? "Change Image" : "Select Image"}
</button>
{form.imageId && (
<button
type="button"
onClick={() => setForm({ ...form, imageId: "" })}
className="px-3 py-2 rounded-lg text-error/70 hover:text-error text-sm transition-colors"
>
Remove
</button>
)}
</div>
</div>
</div>
<div className="flex items-center gap-4 mt-4 pt-4 border-t border-surface-container-highest">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={form.featured}
onChange={(e) => setForm({ ...form, featured: e.target.checked })}
className="hidden"
/>
<span
className={cn(
"flex items-center gap-1.5 text-sm transition-colors",
form.featured ? "text-primary" : "text-on-surface/50 hover:text-on-surface"
)}
>
<Star size={15} className={form.featured ? "fill-primary" : ""} />
Featured
</span>
</label>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.title || !form.date}
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>
)}
{showMediaPicker && (
<MediaPickerModal
selectedId={form.imageId || null}
onSelect={(id) => {
setForm({ ...form, imageId: id });
setShowMediaPicker(false);
}}
onClose={() => setShowMediaPicker(false)}
/>
)}
{/* Filters */}
<div className="flex flex-wrap items-center gap-2">
{/* Status filter */}
<div className="flex items-center bg-surface-container-low rounded-lg overflow-hidden">
{FILTER_STATUS_OPTIONS.map(({ value, label }) => (
<button
key={value}
onClick={() => setFilterStatus(value)}
className={cn(
"px-3 py-1.5 text-xs font-semibold transition-colors",
filterStatus === value
? "bg-primary text-on-primary"
: "text-on-surface/60 hover:text-on-surface hover:bg-surface-container-high"
)}
>
{label}
</button>
))}
</div>
{/* City filter */}
{cities.length > 1 && (
<select
value={filterCity}
onChange={(e) => setFilterCity(e.target.value)}
className="bg-surface-container-low text-on-surface/70 text-xs rounded-lg px-3 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary/40"
>
<option value="ALL">All cities</option>
{cities.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
)}
{/* Sort */}
<button
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-on-surface/60 hover:text-on-surface bg-surface-container-low rounded-lg transition-colors"
>
{sortDir === "asc" ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
Date
</button>
<span className="ml-auto text-xs text-on-surface/40">
{filtered.length} event{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3 border border-primary/20">
<span className="text-sm text-on-surface/70 font-medium">
{selected.size} selected
</span>
<div className="flex items-center gap-2 ml-auto">
<button
onClick={() => handleBulk("duplicate")}
disabled={bulkLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-surface-container-high text-on-surface/70 hover:text-on-surface transition-colors disabled:opacity-50"
>
<Copy size={12} />
Duplicate
</button>
<button
onClick={() => handleBulk("publish")}
disabled={bulkLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-surface-container-high text-on-surface/70 hover:text-on-surface transition-colors disabled:opacity-50"
>
Publish
</button>
<button
onClick={() => handleBulk("delete")}
disabled={bulkLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg bg-error-container/20 text-error/70 hover:text-error transition-colors disabled:opacity-50"
>
<Trash2 size={12} />
Delete
</button>
<button
onClick={() => setSelected(new Set())}
className="ml-1 text-on-surface/40 hover:text-on-surface"
>
<X size={14} />
</button>
</div>
</div>
)}
{/* Event list */}
<div className="space-y-2">
{filtered.length === 0 ? (
<p className="text-on-surface/50 text-sm py-8 text-center">No events found.</p>
) : (
<>
{/* Select-all row */}
<div className="flex items-center gap-2 px-2 pb-1">
<button
onClick={selectAll}
className="text-on-surface/40 hover:text-on-surface transition-colors"
title={allSelected ? "Deselect all" : "Select all"}
>
{allSelected ? <CheckSquare size={15} /> : <Square size={15} />}
</button>
<span className="text-xs text-on-surface/40">
{allSelected ? "Deselect all" : "Select all"}
</span>
</div>
{filtered.map((meetup) => (
<div
key={meetup.id}
className={cn(
"bg-surface-container-low rounded-xl p-4 flex items-center gap-3 transition-colors",
selected.has(meetup.id) && "ring-1 ring-primary/30 bg-surface-container"
)}
>
{/* Checkbox */}
<button
onClick={() => toggleSelect(meetup.id)}
className="shrink-0 text-on-surface/40 hover:text-on-surface transition-colors"
>
{selected.has(meetup.id) ? (
<CheckSquare size={16} className="text-primary" />
) : (
<Square size={16} />
)}
</button>
{/* Image */}
{meetup.imageId ? (
<div className="w-14 h-14 rounded-lg overflow-hidden bg-surface-container-highest shrink-0">
<img
src={`/media/${meetup.imageId}?w=100`}
alt=""
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-14 h-14 rounded-lg bg-surface-container-highest shrink-0 flex items-center justify-center">
<ImageIcon size={18} className="text-on-surface/20" />
</div>
)}
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h3 className="text-on-surface font-semibold truncate">{meetup.title}</h3>
{meetup.featured && (
<Star size={12} className="text-primary fill-primary shrink-0" />
)}
{meetup.visibility === "HIDDEN" && (
<EyeOff size={12} className="text-on-surface/40 shrink-0" />
)}
</div>
<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.location && ` · ${meetup.location}`}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{/* Featured toggle */}
<button
onClick={() => handlePatch(meetup.id, { featured: !meetup.featured })}
className={cn(
"p-2 rounded-lg transition-colors",
meetup.featured
? "text-primary hover:text-primary/70"
: "text-on-surface/30 hover:text-on-surface hover:bg-surface-container-high"
)}
title={meetup.featured ? "Unfeature" : "Feature"}
>
<Star size={15} className={meetup.featured ? "fill-primary" : ""} />
</button>
{/* Visibility toggle */}
<button
onClick={() =>
handlePatch(meetup.id, {
visibility: meetup.visibility === "PUBLIC" ? "HIDDEN" : "PUBLIC",
})
}
className={cn(
"p-2 rounded-lg transition-colors",
meetup.visibility === "HIDDEN"
? "text-on-surface/30 hover:text-on-surface hover:bg-surface-container-high"
: "text-on-surface/60 hover:text-on-surface hover:bg-surface-container-high"
)}
title={meetup.visibility === "PUBLIC" ? "Hide event" : "Make public"}
>
{meetup.visibility === "HIDDEN" ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
{/* Edit */}
<button
onClick={() => openEdit(meetup)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
title="Edit"
>
<Pencil size={15} />
</button>
{/* Duplicate */}
<button
onClick={() => handleDuplicate(meetup.id)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-on-surface transition-colors"
title="Duplicate"
>
<Copy size={15} />
</button>
{/* Delete */}
<button
onClick={() => handleDelete(meetup.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/60 hover:text-error transition-colors"
title="Delete"
>
<Trash2 size={15} />
</button>
{/* More menu */}
<MoreMenu meetup={meetup} onCopyUrl={() => handleCopyUrl(meetup)} />
</div>
</div>
))}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Plus, Pencil, Trash2, X, ChevronUp, ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
interface FaqItem {
id: string;
question: string;
answer: string;
order: number;
showOnHomepage: boolean;
}
interface FaqForm {
question: string;
answer: string;
showOnHomepage: boolean;
}
const emptyForm: FaqForm = {
question: "",
answer: "",
showOnHomepage: true,
};
export default function FaqAdminPage() {
const [faqs, setFaqs] = useState<FaqItem[]>([]);
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<FaqForm>(emptyForm);
const [saving, setSaving] = useState(false);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const loadFaqs = async () => {
try {
const data = await api.getAllFaqs();
setFaqs(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFaqs();
}, []);
const openCreate = () => {
setForm(emptyForm);
setEditingId(null);
setShowForm(true);
};
const openEdit = (faq: FaqItem) => {
setForm({
question: faq.question,
answer: faq.answer,
showOnHomepage: faq.showOnHomepage,
});
setEditingId(faq.id);
setShowForm(true);
};
const handleSave = async () => {
if (!form.question.trim() || !form.answer.trim()) return;
setSaving(true);
setError("");
try {
if (editingId) {
await api.updateFaq(editingId, form);
} else {
await api.createFaq(form);
}
setShowForm(false);
setEditingId(null);
await loadFaqs();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this FAQ?")) return;
try {
await api.deleteFaq(id);
await loadFaqs();
} catch (err: any) {
setError(err.message);
}
};
const handleToggleHomepage = async (faq: FaqItem) => {
try {
await api.updateFaq(faq.id, { showOnHomepage: !faq.showOnHomepage });
setFaqs((prev) =>
prev.map((f) =>
f.id === faq.id ? { ...f, showOnHomepage: !faq.showOnHomepage } : f
)
);
} catch (err: any) {
setError(err.message);
}
};
const moveItem = async (index: number, direction: "up" | "down") => {
const newFaqs = [...faqs];
const targetIndex = direction === "up" ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= newFaqs.length) return;
[newFaqs[index], newFaqs[targetIndex]] = [newFaqs[targetIndex], newFaqs[index]];
const reordered = newFaqs.map((f, i) => ({ ...f, order: i }));
setFaqs(reordered);
try {
await api.reorderFaqs(reordered.map((f) => ({ id: f.id, order: f.order })));
} catch (err: any) {
setError(err.message);
await loadFaqs();
}
};
// Drag-and-drop handlers
const handleDragStart = (index: number) => {
setDragIndex(index);
};
const handleDragEnter = (index: number) => {
setDragOverIndex(index);
};
const handleDragEnd = async () => {
if (dragIndex === null || dragOverIndex === null || dragIndex === dragOverIndex) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const newFaqs = [...faqs];
const [moved] = newFaqs.splice(dragIndex, 1);
newFaqs.splice(dragOverIndex, 0, moved);
const reordered = newFaqs.map((f, i) => ({ ...f, order: i }));
setFaqs(reordered);
setDragIndex(null);
setDragOverIndex(null);
try {
await api.reorderFaqs(reordered.map((f) => ({ id: f.id, order: f.order })));
} catch (err: any) {
setError(err.message);
await loadFaqs();
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading FAQs...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-on-surface">FAQ Management</h1>
<p className="text-on-surface/50 text-sm mt-1">
Drag to reorder · toggle visibility on homepage
</p>
</div>
<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 FAQ
</button>
</div>
{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 FAQ" : "Add FAQ"}
</h2>
<button
onClick={() => setShowForm(false)}
className="text-on-surface/50 hover:text-on-surface"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<input
placeholder="Question"
value={form.question}
onChange={(e) => setForm({ ...form, question: 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"
/>
<textarea
placeholder="Answer"
value={form.answer}
onChange={(e) => setForm({ ...form, answer: e.target.value })}
rows={4}
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 resize-none"
/>
<label className="flex items-center gap-3 cursor-pointer select-none">
<div
onClick={() => setForm({ ...form, showOnHomepage: !form.showOnHomepage })}
className={cn(
"w-11 h-6 rounded-full relative transition-colors",
form.showOnHomepage ? "bg-primary" : "bg-surface-container-highest"
)}
>
<div
className={cn(
"absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform",
form.showOnHomepage ? "translate-x-6" : "translate-x-1"
)}
/>
</div>
<span className="text-on-surface/80 text-sm">Show on homepage</span>
</label>
</div>
<div className="flex gap-3 mt-4">
<button
onClick={handleSave}
disabled={saving || !form.question.trim() || !form.answer.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-2">
{faqs.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-12 text-center">
<p className="text-on-surface/40 text-sm">No FAQs yet. Add one to get started.</p>
</div>
) : (
faqs.map((faq, index) => (
<div
key={faq.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragEnter={() => handleDragEnter(index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => e.preventDefault()}
className={cn(
"bg-surface-container-low rounded-xl p-5 flex items-start gap-4 transition-all",
dragOverIndex === index && dragIndex !== index
? "ring-2 ring-primary/50 bg-surface-container"
: "",
dragIndex === index ? "opacity-50" : ""
)}
>
{/* Drag handle */}
<div className="mt-1 cursor-grab active:cursor-grabbing text-on-surface/30 hover:text-on-surface/60 shrink-0">
<GripVertical size={18} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="text-on-surface font-semibold truncate">{faq.question}</p>
{faq.showOnHomepage ? (
<span className="shrink-0 text-xs bg-green-900/30 text-green-400 rounded-full px-2 py-0.5 font-medium">
Homepage
</span>
) : (
<span className="shrink-0 text-xs bg-surface-container-highest text-on-surface/40 rounded-full px-2 py-0.5 font-medium">
Hidden
</span>
)}
</div>
<p className="text-on-surface/50 text-sm line-clamp-2">{faq.answer}</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => moveItem(index, "up")}
disabled={index === 0}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/40 hover:text-on-surface disabled:opacity-20 transition-colors"
title="Move up"
>
<ChevronUp size={16} />
</button>
<button
onClick={() => moveItem(index, "down")}
disabled={index === faqs.length - 1}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/40 hover:text-on-surface disabled:opacity-20 transition-colors"
title="Move down"
>
<ChevronDown size={16} />
</button>
<button
onClick={() => handleToggleHomepage(faq)}
className={cn(
"p-2 rounded-lg transition-colors",
faq.showOnHomepage
? "hover:bg-surface-container-high text-green-400 hover:text-on-surface"
: "hover:bg-surface-container-high text-on-surface/40 hover:text-on-surface"
)}
title={faq.showOnHomepage ? "Hide from homepage" : "Show on homepage"}
>
{faq.showOnHomepage ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
<button
onClick={() => openEdit(faq)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/40 hover:text-on-surface transition-colors"
title="Edit"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(faq.id)}
className="p-2 rounded-lg hover:bg-error-container/30 text-on-surface/40 hover:text-error transition-colors"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</div>
))
)}
</div>
{faqs.length > 0 && (
<p className="text-on-surface/30 text-xs text-center">
{faqs.filter((f) => f.showOnHomepage).length} of {faqs.length} shown on homepage
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,325 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Upload, Trash2, Copy, Film, Image as ImageIcon, Check, Pencil, X } from "lucide-react";
interface MediaItem {
id: string;
slug: string;
type: "image" | "video";
mimeType: string;
size: number;
originalFilename: string;
uploadedBy: string;
createdAt: string;
url: string;
title?: string;
description?: string;
altText?: string;
}
interface EditForm {
title: string;
description: string;
altText: string;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function GalleryPage() {
const [media, setMedia] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [uploading, setUploading] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [editingItem, setEditingItem] = useState<MediaItem | null>(null);
const [editForm, setEditForm] = useState<EditForm>({ title: "", description: "", altText: "" });
const [saving, setSaving] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadMedia = async () => {
try {
const data = await api.getMediaList();
setMedia(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMedia();
}, []);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setError("");
try {
for (const file of Array.from(files)) {
await api.uploadMedia(file);
}
await loadMedia();
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this media item? This cannot be undone.")) return;
try {
await api.deleteMedia(id);
await loadMedia();
} catch (err: any) {
setError(err.message);
}
};
const handleCopyUrl = async (item: MediaItem) => {
const url = `${window.location.origin}/media/${item.id}`;
await navigator.clipboard.writeText(url);
setCopiedId(item.id);
setTimeout(() => setCopiedId(null), 2000);
};
const openEdit = (item: MediaItem) => {
setEditingItem(item);
setEditForm({
title: item.title || "",
description: item.description || "",
altText: item.altText || "",
});
};
const handleSaveEdit = async () => {
if (!editingItem) return;
setSaving(true);
setError("");
try {
await api.updateMedia(editingItem.id, {
title: editForm.title,
description: editForm.description,
altText: editForm.altText,
});
setEditingItem(null);
await loadMedia();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading gallery...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Media Gallery</h1>
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
multiple
onChange={handleUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
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"
>
<Upload size={16} />
{uploading ? "Uploading..." : "Upload Media"}
</button>
</div>
</div>
{error && <p className="text-error text-sm">{error}</p>}
{media.length === 0 ? (
<div className="text-center py-20">
<ImageIcon size={48} className="mx-auto text-on-surface/20 mb-4" />
<p className="text-on-surface/50 text-sm">No media uploaded yet.</p>
<p className="text-on-surface/30 text-xs mt-1">
Upload images or videos to get started.
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{media.map((item) => (
<div
key={item.id}
className="group bg-surface-container-low rounded-xl overflow-hidden"
>
<div className="relative aspect-square bg-surface-container-highest">
{item.type === "image" ? (
<img
src={`/media/${item.id}?w=300`}
alt={item.altText || item.title || item.originalFilename}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Film size={40} className="text-on-surface/30" />
</div>
)}
<span
className={cn(
"absolute top-2 left-2 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase",
item.type === "image"
? "bg-blue-900/60 text-blue-300"
: "bg-purple-900/60 text-purple-300"
)}
>
{item.type}
</span>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
<button
onClick={() => handleCopyUrl(item)}
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 text-white transition-colors"
title="Copy Full URL"
>
{copiedId === item.id ? <Check size={16} /> : <Copy size={16} />}
</button>
<button
onClick={() => openEdit(item)}
className="p-2 rounded-lg bg-white/20 hover:bg-white/30 text-white transition-colors"
title="Edit Media"
>
<Pencil size={16} />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 rounded-lg bg-red-500/30 hover:bg-red-500/50 text-white transition-colors"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</div>
<div className="p-3">
<p className="text-on-surface text-xs font-medium truncate" title={item.title || item.originalFilename}>
{item.title || item.originalFilename}
</p>
<p className="text-on-surface/40 text-[10px] mt-0.5">
{formatFileSize(item.size)}
</p>
</div>
</div>
))}
</div>
)}
{editingItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={() => setEditingItem(null)} />
<div className="relative bg-surface-container-low rounded-2xl w-full max-w-lg overflow-hidden">
<div className="flex items-center justify-between p-5 border-b border-surface-container-highest">
<h2 className="text-lg font-semibold text-on-surface">Edit Media</h2>
<button
onClick={() => setEditingItem(null)}
className="text-on-surface/50 hover:text-on-surface transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-5 space-y-4">
<div className="flex items-center gap-4 p-3 bg-surface-container-highest rounded-lg">
{editingItem.type === "image" ? (
<img
src={`/media/${editingItem.id}?w=100`}
alt=""
className="w-14 h-14 rounded-lg object-cover shrink-0"
/>
) : (
<div className="w-14 h-14 rounded-lg bg-surface-container flex items-center justify-center shrink-0">
<Film size={20} className="text-on-surface/30" />
</div>
)}
<div className="min-w-0">
<p className="text-on-surface text-sm font-medium truncate">{editingItem.originalFilename}</p>
<p className="text-on-surface/40 text-xs">
{editingItem.type} &middot; {formatFileSize(editingItem.size)} &middot; {editingItem.mimeType}
</p>
</div>
</div>
<div>
<label className="text-on-surface/60 text-xs mb-1.5 block">Title</label>
<input
value={editForm.title}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
placeholder="SEO title for this media"
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 text-sm"
/>
</div>
<div>
<label className="text-on-surface/60 text-xs mb-1.5 block">Description</label>
<textarea
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="SEO description for this media"
rows={3}
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 text-sm resize-none"
/>
</div>
<div>
<label className="text-on-surface/60 text-xs mb-1.5 block">Alt Text</label>
<input
value={editForm.altText}
onChange={(e) => setEditForm({ ...editForm, altText: e.target.value })}
placeholder="Accessible alt text for images"
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 text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-3 p-5 border-t border-surface-container-highest">
<button
onClick={() => setEditingItem(null)}
className="px-5 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveEdit}
disabled={saving}
className="px-5 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>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { AdminSidebar } from "@/components/admin/AdminSidebar";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return;
if (!user) {
router.push("/login");
return;
}
if (user.role !== "ADMIN" && user.role !== "MODERATOR") {
router.push("/dashboard");
}
}, [user, loading, router]);
if (loading || !user || (user.role !== "ADMIN" && user.role !== "MODERATOR")) {
return null;
}
return (
<div className="flex">
<AdminSidebar />
<main className="flex-1 p-8 bg-surface min-h-screen">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import { EyeOff, UserX, Undo2, Plus } from "lucide-react";
type Tab = "hidden" | "blocked";
export default function ModerationPage() {
const [tab, setTab] = useState<Tab>("hidden");
const [hiddenContent, setHiddenContent] = useState<any[]>([]);
const [blockedPubkeys, setBlockedPubkeys] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [hideEventId, setHideEventId] = useState("");
const [hideReason, setHideReason] = useState("");
const [blockPubkey, setBlockPubkey] = useState("");
const [blockReason, setBlockReason] = useState("");
const loadData = async () => {
try {
const [h, b] = await Promise.all([
api.getHiddenContent(),
api.getBlockedPubkeys(),
]);
setHiddenContent(h);
setBlockedPubkeys(b);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleHide = async () => {
if (!hideEventId.trim()) return;
setError("");
try {
await api.hideContent(hideEventId, hideReason || undefined);
setHideEventId("");
setHideReason("");
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const handleUnhide = async (id: string) => {
try {
await api.unhideContent(id);
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const handleBlock = async () => {
if (!blockPubkey.trim()) return;
setError("");
try {
await api.blockPubkey(blockPubkey, blockReason || undefined);
setBlockPubkey("");
setBlockReason("");
await loadData();
} catch (err: any) {
setError(err.message);
}
};
const handleUnblock = async (id: string) => {
try {
await api.unblockPubkey(id);
await loadData();
} 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 moderation data...</div>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">Moderation</h1>
{error && <p className="text-error text-sm">{error}</p>}
<div className="flex gap-2">
<button
onClick={() => setTab("hidden")}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors",
tab === "hidden"
? "bg-surface-container-high text-primary"
: "bg-surface-container-low text-on-surface/60 hover:text-on-surface"
)}
>
<EyeOff size={16} />
Hidden Content
</button>
<button
onClick={() => setTab("blocked")}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors",
tab === "blocked"
? "bg-surface-container-high text-primary"
: "bg-surface-container-low text-on-surface/60 hover:text-on-surface"
)}
>
<UserX size={16} />
Blocked Pubkeys
</button>
</div>
{tab === "hidden" && (
<div className="space-y-4">
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Hide Content</h2>
<div className="flex gap-3">
<input
placeholder="Nostr event ID"
value={hideEventId}
onChange={(e) => setHideEventId(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"
/>
<input
placeholder="Reason (optional)"
value={hideReason}
onChange={(e) => setHideReason(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"
/>
<button
onClick={handleHide}
disabled={!hideEventId.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"
>
<Plus size={16} />
Hide
</button>
</div>
</div>
<div className="space-y-3">
{hiddenContent.length === 0 ? (
<p className="text-on-surface/50 text-sm">No hidden content.</p>
) : (
hiddenContent.map((item) => (
<div
key={item.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<p className="text-on-surface font-mono text-sm">
{item.nostrEventId?.slice(0, 16)}...
</p>
{item.reason && (
<p className="text-on-surface/50 text-xs mt-1">{item.reason}</p>
)}
{item.createdAt && (
<p className="text-on-surface/40 text-xs mt-1">
{formatDate(item.createdAt)}
</p>
)}
</div>
<button
onClick={() => handleUnhide(item.id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-on-surface text-sm transition-colors"
>
<Undo2 size={14} />
Unhide
</button>
</div>
))
)}
</div>
</div>
)}
{tab === "blocked" && (
<div className="space-y-4">
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-sm font-semibold text-on-surface/70 mb-3">Block Pubkey</h2>
<div className="flex gap-3">
<input
placeholder="Pubkey (hex)"
value={blockPubkey}
onChange={(e) => setBlockPubkey(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"
/>
<input
placeholder="Reason (optional)"
value={blockReason}
onChange={(e) => setBlockReason(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"
/>
<button
onClick={handleBlock}
disabled={!blockPubkey.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"
>
<Plus size={16} />
Block
</button>
</div>
</div>
<div className="space-y-3">
{blockedPubkeys.length === 0 ? (
<p className="text-on-surface/50 text-sm">No blocked pubkeys.</p>
) : (
blockedPubkeys.map((item) => (
<div
key={item.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<p className="text-on-surface font-mono text-sm">
{item.pubkey?.slice(0, 16)}...{item.pubkey?.slice(-8)}
</p>
{item.reason && (
<p className="text-on-surface/50 text-xs mt-1">{item.reason}</p>
)}
{item.createdAt && (
<p className="text-on-surface/40 text-xs mt-1">
{formatDate(item.createdAt)}
</p>
)}
</div>
<button
onClick={() => handleUnblock(item.id)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 hover:text-on-surface text-sm transition-colors"
>
<Undo2 size={14} />
Unblock
</button>
</div>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
import { Search, RefreshCw, Bug } from "lucide-react";
export default function NostrToolsPage() {
const [fetchInput, setFetchInput] = useState("");
const [fetchResult, setFetchResult] = useState<any>(null);
const [fetching, setFetching] = useState(false);
const [cacheStatus, setCacheStatus] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [debugInput, setDebugInput] = useState("");
const [debugResult, setDebugResult] = useState<any>(null);
const [debugging, setDebugging] = useState(false);
const [error, setError] = useState("");
const handleFetch = async () => {
if (!fetchInput.trim()) return;
setFetching(true);
setError("");
setFetchResult(null);
try {
const isNaddr = fetchInput.startsWith("naddr");
const data = await api.fetchNostrEvent(
isNaddr ? { naddr: fetchInput } : { eventId: fetchInput }
);
setFetchResult(data);
} catch (err: any) {
setError(err.message);
} finally {
setFetching(false);
}
};
const handleRefreshCache = async () => {
setRefreshing(true);
setCacheStatus("");
setError("");
try {
const result = await api.refreshCache();
setCacheStatus(result.message || "Cache refreshed successfully.");
} catch (err: any) {
setError(err.message);
} finally {
setRefreshing(false);
}
};
const handleDebug = async () => {
if (!debugInput.trim()) return;
setDebugging(true);
setError("");
setDebugResult(null);
try {
const data = await api.debugEvent(debugInput);
setDebugResult(data);
} catch (err: any) {
setError(err.message);
} finally {
setDebugging(false);
}
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">Nostr Tools</h1>
{error && <p className="text-error text-sm">{error}</p>}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4 flex items-center gap-2">
<Search size={18} />
Manual Fetch
</h2>
<div className="flex gap-3">
<input
placeholder="Event ID or naddr..."
value={fetchInput}
onChange={(e) => setFetchInput(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"
/>
<button
onClick={handleFetch}
disabled={fetching || !fetchInput.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"
>
{fetching ? "Fetching..." : "Fetch"}
</button>
</div>
{fetchResult && (
<pre className="mt-4 bg-surface-container rounded-lg p-4 text-on-surface/80 text-xs overflow-x-auto max-h-96 overflow-y-auto font-mono">
{JSON.stringify(fetchResult, null, 2)}
</pre>
)}
</div>
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4 flex items-center gap-2">
<RefreshCw size={18} />
Cache Management
</h2>
<button
onClick={handleRefreshCache}
disabled={refreshing}
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"
>
<RefreshCw size={16} className={refreshing ? "animate-spin" : ""} />
{refreshing ? "Refreshing..." : "Refresh Cache"}
</button>
{cacheStatus && (
<p className="mt-3 text-green-400 text-sm">{cacheStatus}</p>
)}
</div>
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4 flex items-center gap-2">
<Bug size={18} />
Debug Event
</h2>
<div className="flex gap-3">
<input
placeholder="Event ID..."
value={debugInput}
onChange={(e) => setDebugInput(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"
/>
<button
onClick={handleDebug}
disabled={debugging || !debugInput.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"
>
{debugging ? "Debugging..." : "Debug"}
</button>
</div>
{debugResult && (
<pre className="mt-4 bg-surface-container rounded-lg p-4 text-on-surface/80 text-xs overflow-x-auto max-h-96 overflow-y-auto font-mono">
{JSON.stringify(debugResult, null, 2)}
</pre>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { Calendar, FileText, Tag, User, Plus, Download, FolderOpen } from "lucide-react";
import Link from "next/link";
export default function OverviewPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const [meetups, setMeetups] = useState<any[]>([]);
const [posts, setPosts] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!authLoading && !user) {
router.push("/admin");
}
}, [authLoading, user, router]);
useEffect(() => {
if (!user) return;
async function load() {
try {
const [m, p, c] = await Promise.all([
api.getMeetups(),
api.getPosts({ limit: 5, all: true }),
api.getCategories(),
]);
setMeetups(Array.isArray(m) ? m : []);
setPosts(p.posts || []);
setCategories(Array.isArray(c) ? c : []);
} catch (err: any) {
setError(err.message || "Failed to load dashboard data");
} finally {
setLoading(false);
}
}
load();
}, [user]);
if (authLoading || !user) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading...</div>
</div>
);
}
const shortPubkey = `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`;
const upcomingMeetup = meetups.find(
(m) => new Date(m.date) > new Date()
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading dashboard...</div>
</div>
);
}
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-on-surface">Welcome back</h1>
<p className="text-on-surface/60 font-mono text-sm mt-1">{shortPubkey}</p>
</div>
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-4 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<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} />
</div>
{upcomingMeetup && (
<div className="bg-surface-container-low rounded-xl p-6">
<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}
</p>
</div>
)}
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4">Recent Posts</h2>
{posts.length === 0 ? (
<p className="text-on-surface/50 text-sm">No posts yet.</p>
) : (
<div className="space-y-3">
{posts.slice(0, 5).map((post: any) => (
<div
key={post.id}
className="flex items-center justify-between py-2"
>
<div>
<p className="text-on-surface text-sm font-medium">{post.title}</p>
<p className="text-on-surface/50 text-xs">{post.slug}</p>
</div>
{post.featured && (
<span className="rounded-full px-3 py-1 text-xs font-bold bg-primary/20 text-primary">
Featured
</span>
)}
</div>
))}
</div>
)}
</div>
<div className="bg-surface-container-low rounded-xl p-6">
<h2 className="text-lg font-semibold text-on-surface mb-4">Quick Actions</h2>
<div className="flex flex-wrap gap-3">
<Link
href="/admin/events"
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} />
Create Meetup
</Link>
<Link
href="/admin/blog"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
<Download size={16} />
Import Post
</Link>
<Link
href="/admin/categories"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface font-semibold text-sm hover:bg-surface-container-high transition-colors"
>
<FolderOpen size={16} />
Manage Categories
</Link>
</div>
</div>
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
}: {
icon: any;
label: string;
value: string | number;
}) {
return (
<div className="bg-surface-container-low rounded-xl p-6">
<div className="flex items-center gap-3 mb-3">
<Icon size={20} className="text-primary" />
<span className="text-on-surface/60 text-sm">{label}</span>
</div>
<p className="text-2xl font-bold text-on-surface">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { LogIn } from "lucide-react";
export default function AdminPage() {
const { user, loading, login } = useAuth();
const router = useRouter();
const [error, setError] = useState("");
const [loggingIn, setLoggingIn] = useState(false);
useEffect(() => {
if (loading) return;
if (!user) return;
if (user.role === "ADMIN" || user.role === "MODERATOR") {
router.push("/admin/overview");
} else {
router.push("/dashboard");
}
}, [user, loading, router]);
const handleLogin = async () => {
setError("");
setLoggingIn(true);
try {
await login();
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setLoggingIn(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading...</div>
</div>
);
}
if (user) return null;
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="bg-surface-container-low rounded-xl p-8 max-w-md w-full text-center">
<h1 className="text-2xl font-bold text-on-surface mb-2">Admin Dashboard</h1>
<p className="text-on-surface/60 mb-6">
Sign in with your Nostr identity to access the admin panel.
</p>
<button
onClick={handleLogin}
disabled={loggingIn}
className="w-full flex items-center justify-center gap-3 px-6 py-3 rounded-lg font-semibold transition-all bg-gradient-to-r from-primary to-primary-container text-on-primary hover:opacity-90 disabled:opacity-50"
>
<LogIn size={20} />
{loggingIn ? "Connecting..." : "Login with Nostr"}
</button>
{error && (
<p className="mt-4 text-error text-sm">{error}</p>
)}
<p className="mt-6 text-on-surface/40 text-xs leading-relaxed">
You need a Nostr browser extension (e.g. Alby, nos2x, or Flamingo) to sign in.
Your pubkey must be registered as an admin or moderator.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { Plus, Pencil, Trash2, X, Wifi, WifiOff, Zap } from "lucide-react";
export default function RelaysPage() {
const [relays, setRelays] = 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({ url: "", priority: 0 });
const [saving, setSaving] = useState(false);
const [testResults, setTestResults] = useState<Record<string, boolean | null>>({});
const loadRelays = async () => {
try {
const data = await api.getRelays();
setRelays(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRelays();
}, []);
const openCreate = () => {
setForm({ url: "", priority: 0 });
setEditingId(null);
setShowForm(true);
};
const openEdit = (relay: any) => {
setForm({ url: relay.url, priority: relay.priority || 0 });
setEditingId(relay.id);
setShowForm(true);
};
const handleSave = async () => {
if (!form.url.trim()) return;
setSaving(true);
setError("");
try {
if (editingId) {
await api.updateRelay(editingId, form);
} else {
await api.addRelay(form);
}
setShowForm(false);
setEditingId(null);
await loadRelays();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this relay?")) return;
try {
await api.deleteRelay(id);
await loadRelays();
} catch (err: any) {
setError(err.message);
}
};
const handleTest = async (id: string) => {
setTestResults((prev) => ({ ...prev, [id]: null }));
try {
const result = await api.testRelay(id);
setTestResults((prev) => ({ ...prev, [id]: result.success }));
} catch {
setTestResults((prev) => ({ ...prev, [id]: false }));
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading relays...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">Relay Configuration</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 Relay
</button>
</div>
{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 Relay" : "Add Relay"}
</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-3 gap-4">
<input
placeholder="wss://relay.example.com"
value={form.url}
onChange={(e) => setForm({ ...form, url: 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 md:col-span-2"
/>
<input
type="number"
placeholder="Priority"
value={form.priority}
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) || 0 })}
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.url.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">
{relays.length === 0 ? (
<p className="text-on-surface/50 text-sm">No relays configured.</p>
) : (
relays.map((relay) => (
<div
key={relay.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div
className={cn(
"p-2 rounded-lg",
relay.active !== false
? "bg-green-900/30 text-green-400"
: "bg-surface-container-highest text-on-surface/40"
)}
>
{relay.active !== false ? <Wifi size={16} /> : <WifiOff size={16} />}
</div>
<div>
<p className="text-on-surface font-mono text-sm">{relay.url}</p>
<div className="flex items-center gap-3 mt-1">
<span className="text-on-surface/50 text-xs">
Priority: {relay.priority ?? 0}
</span>
{testResults[relay.id] !== undefined && (
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-bold",
testResults[relay.id] === null
? "bg-surface-container-highest text-on-surface/50"
: testResults[relay.id]
? "bg-green-900/30 text-green-400"
: "bg-error-container/30 text-error"
)}
>
{testResults[relay.id] === null
? "Testing..."
: testResults[relay.id]
? "Connected"
: "Failed"}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleTest(relay.id)}
className="p-2 rounded-lg hover:bg-surface-container-high text-on-surface/60 hover:text-primary transition-colors"
title="Test connection"
>
<Zap size={16} />
</button>
<button
onClick={() => openEdit(relay)}
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(relay.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>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Save } from "lucide-react";
const settingFields = [
{ key: "site_title", label: "Site Title" },
{ key: "site_tagline", label: "Site Tagline" },
{ key: "telegram_link", label: "Telegram Link" },
{ key: "nostr_link", label: "Nostr Link" },
{ key: "x_link", label: "X Link" },
{ key: "youtube_link", label: "YouTube Link" },
{ key: "discord_link", label: "Discord Link" },
{ key: "linkedin_link", label: "LinkedIn Link" },
];
export default function SettingsPage() {
const [settings, setSettings] = useState<Record<string, string>>({});
const [original, setOriginal] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
async function load() {
try {
const data = await api.getSettings();
setSettings(data);
setOriginal(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
load();
}, []);
const handleSave = async () => {
setSaving(true);
setError("");
setSuccess("");
try {
const changed = Object.entries(settings).filter(
([key, value]) => value !== (original[key] || "")
);
await Promise.all(
changed.map(([key, value]) => api.updateSetting(key, value))
);
setOriginal({ ...settings });
setSuccess("Settings saved successfully.");
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const hasChanges = Object.entries(settings).some(
([key, value]) => value !== (original[key] || "")
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading settings...</div>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">Site Settings</h1>
{error && <p className="text-error text-sm">{error}</p>}
{success && <p className="text-green-400 text-sm">{success}</p>}
<div className="bg-surface-container-low rounded-xl p-6 space-y-4">
{settingFields.map((field) => (
<div key={field.key}>
<label className="block text-on-surface/70 text-sm mb-1">{field.label}</label>
<input
value={settings[field.key] || ""}
onChange={(e) =>
setSettings({ ...settings, [field.key]: 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>
))}
<button
onClick={handleSave}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-6 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 disabled:opacity-50 mt-2"
>
<Save size={16} />
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
import { formatDate } from "@/lib/utils";
import {
Clock,
CheckCircle,
XCircle,
Inbox,
} from "lucide-react";
interface Submission {
id: string;
eventId?: string;
naddr?: string;
title: string;
authorPubkey: string;
status: string;
reviewedBy?: string;
reviewNote?: string;
createdAt: string;
}
type FilterStatus = "ALL" | "PENDING" | "APPROVED" | "REJECTED";
const TABS: { value: FilterStatus; label: string }[] = [
{ value: "ALL", label: "All" },
{ value: "PENDING", label: "Pending" },
{ value: "APPROVED", label: "Approved" },
{ value: "REJECTED", label: "Rejected" },
];
export default function AdminSubmissionsPage() {
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [filter, setFilter] = useState<FilterStatus>("PENDING");
const [reviewingId, setReviewingId] = useState<string | null>(null);
const [reviewNote, setReviewNote] = useState("");
const [processing, setProcessing] = useState(false);
const loadSubmissions = async () => {
try {
const status = filter === "ALL" ? undefined : filter;
const data = await api.getSubmissions(status);
setSubmissions(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
setLoading(true);
loadSubmissions();
}, [filter]);
const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => {
setProcessing(true);
setError("");
try {
await api.reviewSubmission(id, { status, reviewNote: reviewNote.trim() || undefined });
setReviewingId(null);
setReviewNote("");
await loadSubmissions();
} catch (err: any) {
setError(err.message);
} finally {
setProcessing(false);
}
};
const pendingCount = submissions.filter((s) => s.status === "PENDING").length;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-on-surface">User Submissions</h1>
{pendingCount > 0 && filter !== "PENDING" && (
<span className="text-xs font-bold bg-primary/10 text-primary px-3 py-1 rounded-full">
{pendingCount} pending
</span>
)}
</div>
{error && <p className="text-error text-sm">{error}</p>}
<div className="flex gap-2">
{TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setFilter(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${
filter === tab.value
? "bg-primary/20 text-primary"
: "bg-surface-container-highest text-on-surface/60 hover:text-on-surface"
}`}
>
{tab.label}
</button>
))}
</div>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-surface-container-low rounded-xl p-6">
<div className="h-5 w-2/3 bg-surface-container-high rounded mb-3" />
<div className="h-4 w-1/3 bg-surface-container-high rounded" />
</div>
))}
</div>
) : submissions.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<Inbox size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">
No {filter !== "ALL" ? filter.toLowerCase() : ""} submissions.
</p>
</div>
) : (
<div className="space-y-3">
{submissions.map((sub) => (
<div
key={sub.id}
className="bg-surface-container-low rounded-xl p-6"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-on-surface">{sub.title}</h3>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-on-surface-variant/60 mt-1">
<span>by {shortenPubkey(sub.authorPubkey)}</span>
<span>{formatDate(sub.createdAt)}</span>
{sub.eventId && (
<span className="font-mono">{sub.eventId.slice(0, 16)}...</span>
)}
{sub.naddr && (
<span className="font-mono">{sub.naddr.slice(0, 20)}...</span>
)}
</div>
</div>
<StatusBadge status={sub.status} />
</div>
{sub.reviewNote && (
<p className="mt-3 text-sm text-on-surface-variant bg-surface-container-high rounded-lg px-4 py-2">
{sub.reviewNote}
</p>
)}
{sub.status === "PENDING" && (
<div className="mt-4">
{reviewingId === sub.id ? (
<div className="space-y-3">
<textarea
value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)}
placeholder="Optional review note..."
rows={2}
className="w-full bg-surface-container-highest text-on-surface rounded-lg px-4 py-3 text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40 resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleReview(sub.id, "APPROVED")}
disabled={processing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-green-500/20 text-green-400 text-sm font-semibold hover:bg-green-500/30 transition-colors disabled:opacity-50"
>
<CheckCircle size={14} />
Approve
</button>
<button
onClick={() => handleReview(sub.id, "REJECTED")}
disabled={processing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-error/20 text-error text-sm font-semibold hover:bg-error/30 transition-colors disabled:opacity-50"
>
<XCircle size={14} />
Reject
</button>
<button
onClick={() => {
setReviewingId(null);
setReviewNote("");
}}
className="px-4 py-2 rounded-lg bg-surface-container-highest text-on-surface/60 text-sm font-semibold hover:text-on-surface transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setReviewingId(sub.id)}
className="text-sm font-semibold text-primary hover:underline"
>
Review
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { icon: typeof Clock; className: string; label: string }> = {
PENDING: { icon: Clock, className: "text-primary bg-primary/10", label: "Pending" },
APPROVED: { icon: CheckCircle, className: "text-green-400 bg-green-400/10", label: "Approved" },
REJECTED: { icon: XCircle, className: "text-error bg-error/10", label: "Rejected" },
};
const cfg = config[status] || config.PENDING;
const Icon = cfg.icon;
return (
<span className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap ${cfg.className}`}>
<Icon size={14} />
{cfg.label}
</span>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import { ShieldCheck, ShieldOff, UserPlus } from "lucide-react";
export default function UsersPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [promotePubkey, setPromotePubkey] = useState("");
const [promoting, setPromoting] = useState(false);
const loadUsers = async () => {
try {
const data = await api.getUsers();
setUsers(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const handlePromote = async () => {
if (!promotePubkey.trim()) return;
setPromoting(true);
setError("");
try {
await api.promoteUser(promotePubkey);
setPromotePubkey("");
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);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading users...</div>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-on-surface">User Management</h1>
{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">
<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"
/>
<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"
>
<UserPlus size={16} />
{promoting ? "Promoting..." : "Promote"}
</button>
</div>
</div>
<div className="space-y-3">
{users.length === 0 ? (
<p className="text-on-surface/50 text-sm">No users found.</p>
) : (
users.map((user) => (
<div
key={user.pubkey || user.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
>
<div>
<p className="text-on-surface font-mono text-sm">
{user.pubkey?.slice(0, 12)}...{user.pubkey?.slice(-8)}
</p>
<div className="flex items-center gap-3 mt-2">
<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>
{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>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,400 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { ArrowLeft, Heart, Send } from "lucide-react";
import ReactMarkdown 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 { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import type { Components } from "react-markdown";
interface Post {
id: string;
slug: string;
title: string;
content: string;
excerpt?: string;
authorName?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
nostrEventId?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
}
interface NostrReply {
id: string;
pubkey: string;
content: string;
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>
),
};
function ArticleSkeleton() {
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
return (
<div className="animate-pulse max-w-3xl mx-auto">
<div className="flex gap-2 mb-6">
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
</div>
<div className="h-12 w-3/4 bg-surface-container-high rounded mb-4" />
<div className="h-12 w-1/2 bg-surface-container-high rounded mb-8" />
<div className="h-5 w-48 bg-surface-container-high rounded mb-16" />
<div className="space-y-4">
{widths.map((w, i) => (
<div
key={i}
className="h-4 bg-surface-container-high rounded"
style={{ width: `${w}%` }}
/>
))}
</div>
</div>
);
}
export default function BlogPostClient({ slug }: { slug: string }) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [comment, setComment] = useState("");
const [replies, setReplies] = useState<NostrReply[]>([]);
const [hasNostr, setHasNostr] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
useEffect(() => {
setHasNostr(hasNostrExtension());
}, []);
useEffect(() => {
if (!slug) return;
setLoading(true);
setError(null);
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]);
useEffect(() => {
if (!slug) return;
api.getPostReactions(slug)
.then((data) => setLikeCount(data.count))
.catch(() => {});
api.getPostReplies(slug)
.then((data) => setReplies(data.replies || []))
.catch(() => {});
}, [slug]);
const handleLike = useCallback(async () => {
if (liked || !post?.nostrEventId || !hasNostr) return;
try {
const pubkey = await getPublicKey();
const reactionEvent = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", post.nostrEventId], ["p", post.authorPubkey || ""]],
content: "+",
pubkey,
};
const signedReaction = await signEvent(reactionEvent);
await publishEvent(signedReaction);
setLiked(true);
setLikeCount((c) => c + 1);
} catch {
// User rejected or extension unavailable
}
}, [liked, post, hasNostr]);
const handleComment = useCallback(async () => {
if (!comment.trim() || !post?.nostrEventId || !hasNostr) return;
setSubmitting(true);
try {
const pubkey = await getPublicKey();
const replyEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", post.nostrEventId, "", "reply"], ["p", post.authorPubkey || ""]],
content: comment.trim(),
pubkey,
};
const signed = await signEvent(replyEvent);
await publishEvent(signed);
setReplies((prev) => [
...prev,
{
id: signed.id || Date.now().toString(),
pubkey,
content: comment.trim(),
created_at: Math.floor(Date.now() / 1000),
},
]);
setComment("");
} catch {
// User rejected or extension unavailable
} finally {
setSubmitting(false);
}
}, [comment, post, hasNostr]);
const categories = post?.categories?.map((c) => c.category) || [];
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-12 pb-24">
<Link
href="/blog"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-12 text-sm font-medium"
>
<ArrowLeft size={16} />
Back to Blog
</Link>
{loading && <ArticleSkeleton />}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
Failed to load post: {error}
</div>
)}
{!loading && !error && post && (
<>
<header className="mb-16">
{categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{categories.map((cat) => (
<span
key={cat.id}
className="px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full"
>
{cat.name}
</span>
))}
</div>
)}
<h1 className="text-4xl md:text-5xl font-black tracking-tight leading-tight mb-6">
{post.title}
</h1>
<div className="flex items-center gap-3 text-sm text-on-surface-variant/60">
{(authorProfile || post.authorName || post.authorPubkey) && (
<div className="flex items-center gap-2.5">
{authorProfile?.picture && (
<img
src={authorProfile.picture}
alt={authorProfile.name || post.authorName || "Author"}
className="w-8 h-8 rounded-full object-cover bg-zinc-800 shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<span className="font-medium text-on-surface-variant">
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
</span>
</div>
)}
{(post.publishedAt || post.createdAt) && (
<>
{(authorProfile || post.authorName || post.authorPubkey) && (
<span className="text-on-surface-variant/30">·</span>
)}
<span>
{formatDate(post.publishedAt || post.createdAt!)}
</span>
</>
)}
</div>
</header>
<article className="mb-16">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{post.content}
</ReactMarkdown>
</article>
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
<div className="flex items-center gap-6 mb-8">
<button
onClick={handleLike}
disabled={!hasNostr}
title={hasNostr ? "Like this post" : "Install a Nostr extension to interact"}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
liked
? "bg-primary/20 text-primary"
: hasNostr
? "bg-surface-container-high text-on-surface hover:bg-surface-bright"
: "bg-surface-container-high text-on-surface/40 cursor-not-allowed"
}`}
>
<Heart size={18} fill={liked ? "currentColor" : "none"} />
<span className="font-semibold">{likeCount}</span>
</button>
{!hasNostr && (
<span className="text-on-surface-variant/50 text-xs">
Install a Nostr extension to like and comment
</span>
)}
</div>
<h3 className="text-lg font-bold mb-6">
Comments {replies.length > 0 && `(${replies.length})`}
</h3>
{hasNostr && (
<div className="flex gap-3 mb-8">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Share your thoughts..."
rows={3}
className="flex-1 bg-surface-container-highest text-on-surface rounded-lg p-4 resize-none placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button
onClick={handleComment}
disabled={!comment.trim() || submitting}
className="self-end px-4 py-3 bg-primary text-on-primary rounded-lg font-semibold hover:scale-105 transition-transform disabled:opacity-30 disabled:cursor-not-allowed"
>
<Send size={18} />
</button>
</div>
)}
{replies.length > 0 ? (
<div className="space-y-6">
{replies.map((r) => (
<div
key={r.id}
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>
<span className="text-on-surface-variant/30">·</span>
<span className="text-xs text-on-surface-variant/50">
{formatDate(new Date(r.created_at * 1000))}
</span>
</div>
<p className="text-on-surface-variant text-sm leading-relaxed">
{r.content}
</p>
</div>
))}
</div>
) : (
<p className="text-on-surface-variant/50 text-sm">
No comments yet. Be the first to share your thoughts.
</p>
)}
</section>
</>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,84 @@
import type { Metadata } from "next";
import BlogPostClient from "./BlogPostClient";
import { BlogPostingJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
async function fetchPost(slug: string) {
try {
const res = await fetch(`${apiUrl}/posts/${slug}`, {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await fetchPost(slug);
if (!post) {
return { title: "Post Not Found" };
}
const description =
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`;
return {
title: post.title,
description,
openGraph: {
type: "article",
title: post.title,
description,
publishedTime: post.publishedAt || post.createdAt,
authors: [author],
images: [{ url: ogImageUrl, width: 1200, height: 630, alt: post.title }],
},
twitter: {
card: "summary_large_image",
title: post.title,
description,
images: [ogImageUrl],
},
alternates: { canonical: `/blog/${slug}` },
};
}
export default async function BlogDetailPage({ params }: Props) {
const { slug } = await params;
const post = await fetchPost(slug);
return (
<>
{post && (
<>
<BlogPostingJsonLd
title={post.title}
description={post.excerpt || `Read "${post.title}" on the Belgian Bitcoin Embassy blog.`}
slug={slug}
publishedAt={post.publishedAt || post.createdAt}
authorName={post.authorName}
/>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Blog", href: "/blog" },
{ name: post.title, href: `/blog/${slug}` },
]}
/>
</>
)}
<BlogPostClient slug={slug} />
</>
);
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Blog - Curated Bitcoin Content from Nostr",
description:
"Read curated Bitcoin articles from the Nostr network. Education, technical analysis, and community insights from the Belgian Bitcoin Embassy.",
openGraph: {
title: "Blog - Belgian Bitcoin Embassy",
description:
"Curated Bitcoin content from the Nostr network. Education, analysis, and insights.",
},
alternates: { canonical: "/blog" },
};
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return children;
}

283
frontend/app/blog/page.tsx Normal file
View File

@@ -0,0 +1,283 @@
"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";
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;
}
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;
return (
<>
<Navbar />
<div className="min-h-screen">
<header className="pt-24 pb-16 px-8">
<div className="max-w-7xl mx-auto">
<p className="uppercase tracking-[0.2em] text-primary mb-4 font-semibold text-sm">
From the Nostr Network
</p>
<h1 className="text-5xl md:text-7xl font-black tracking-tighter mb-4">
Blog
</h1>
<p className="text-xl text-on-surface-variant max-w-xl leading-relaxed">
Curated Bitcoin content from the Nostr network
</p>
</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>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
export async function GET() {
let upstream: Response;
try {
upstream = await fetch(`${API_URL}/calendar/ics`, {
headers: { Accept: 'text/calendar' },
cache: 'no-store',
});
} catch {
return new NextResponse('Calendar service unavailable', { status: 502 });
}
if (!upstream.ok) {
return new NextResponse('Failed to fetch calendar', { status: upstream.status });
}
const body = await upstream.text();
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'Content-Disposition': 'inline; filename="bbe-events.ics"',
},
});
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Community - Connect with Belgian Bitcoiners",
description:
"Join the Belgian Bitcoin Embassy community on Telegram, Nostr, X, YouTube, Discord, and LinkedIn. Connect with Bitcoiners across Belgium.",
openGraph: {
title: "Community - Belgian Bitcoin Embassy",
description:
"Connect with Belgian Bitcoiners across every platform.",
},
alternates: { canonical: "/community" },
};
export default function CommunityLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,33 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { CommunityLinksSection } from "@/components/public/CommunityLinksSection";
export default function CommunityPage() {
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
api.getPublicSettings()
.then((data) => setSettings(data))
.catch(() => {});
}, []);
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-16 pb-4">
<h1 className="text-4xl font-black mb-4">Community</h1>
<p className="text-on-surface-variant text-lg">
Connect with Belgian Bitcoiners across every platform.
</p>
</div>
<CommunityLinksSection settings={settings} />
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,86 @@
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";
export const metadata: Metadata = {
title: "Contact Us",
description:
"Get in touch with the Belgian Bitcoin Embassy community through Telegram, Nostr, or X. Join our monthly Bitcoin meetups in Belgium.",
openGraph: {
title: "Contact the Belgian Bitcoin Embassy",
description:
"Reach the Belgian Bitcoin community through our decentralized channels.",
},
alternates: { canonical: "/contact" },
};
export default function ContactPage() {
return (
<>
<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-4">Contact</h1>
<p className="text-on-surface-variant text-lg mb-12">
The best way to reach us is through our community channels. We are a
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>
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return;
if (!user) {
router.push("/login");
return;
}
if (user.role === "ADMIN" || user.role === "MODERATOR") {
router.push("/admin/overview");
}
}, [user, loading, router]);
if (loading) {
return (
<>
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading...</div>
</div>
<Footer />
</>
);
}
if (!user || user.role === "ADMIN" || user.role === "MODERATOR") {
return null;
}
return (
<>
<Navbar />
<main className="min-h-screen max-w-5xl mx-auto px-8 py-12">{children}</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,521 @@
"use client";
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 { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { shortenPubkey } from "@/lib/nostr";
import { formatDate } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
interface Submission {
id: string;
eventId?: string;
naddr?: string;
title: string;
status: string;
reviewNote?: string;
createdAt: string;
}
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
PENDING: {
label: "Pending Review",
icon: Clock,
className: "text-primary bg-primary/10",
},
APPROVED: {
label: "Approved",
icon: CheckCircle,
className: "text-green-400 bg-green-400/10",
},
REJECTED: {
label: "Rejected",
icon: XCircle,
className: "text-error bg-error/10",
},
};
type Tab = "submissions" | "profile";
type UsernameStatus =
| { state: "idle" }
| { state: "checking" }
| { state: "available" }
| { state: "unavailable"; reason: string };
export default function DashboardPage() {
const { user, login } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>("submissions");
// Submissions state
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 [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState("");
const [formSuccess, setFormSuccess] = useState("");
// Profile state
const [username, setUsername] = useState("");
const [usernameStatus, setUsernameStatus] = useState<UsernameStatus>({ state: "idle" });
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState("");
const [hostname, setHostname] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
useEffect(() => {
setHostname(window.location.hostname);
}, []);
useEffect(() => {
if (user?.username) {
setUsername(user.username);
}
}, [user?.username]);
const loadSubmissions = useCallback(async () => {
try {
const data = await api.getMySubmissions();
setSubmissions(data);
} catch {
// Silently handle
} finally {
setLoadingSubs(false);
}
}, []);
useEffect(() => {
loadSubmissions();
}, [loadSubmissions]);
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");
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("");
setShowForm(false);
await loadSubmissions();
} catch (err: any) {
setFormError(err.message || "Failed to submit");
} finally {
setSubmitting(false);
}
};
const handleUsernameChange = (value: string) => {
setUsername(value);
setSaveError("");
setSaveSuccess("");
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = value.trim().toLowerCase();
if (!trimmed || trimmed === (user?.username ?? "")) {
setUsernameStatus({ state: "idle" });
return;
}
setUsernameStatus({ state: "checking" });
debounceRef.current = setTimeout(async () => {
try {
const result = await api.checkUsername(trimmed);
if (result.available) {
setUsernameStatus({ state: "available" });
} else {
setUsernameStatus({ state: "unavailable", reason: result.reason || "Username is not available" });
}
} catch {
setUsernameStatus({ state: "unavailable", reason: "Could not check availability" });
}
}, 500);
};
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaveError("");
setSaveSuccess("");
const trimmed = username.trim().toLowerCase();
if (!trimmed) {
setSaveError("Username is required");
return;
}
setSaving(true);
try {
const updated = await api.updateProfile({ username: trimmed });
setSaveSuccess(`Username saved! Your NIP-05 address is ${updated.username}@${hostname}`);
setUsernameStatus({ state: "idle" });
// Persist updated username into stored user
const stored = localStorage.getItem("bbe_user");
if (stored) {
try {
const parsed = JSON.parse(stored);
localStorage.setItem("bbe_user", JSON.stringify({ ...parsed, username: updated.username }));
} catch {
// ignore
}
}
} catch (err: any) {
setSaveError(err.message || "Failed to save username");
} finally {
setSaving(false);
}
};
const isSaveDisabled =
saving ||
usernameStatus.state === "checking" ||
usernameStatus.state === "unavailable" ||
!username.trim();
return (
<div>
<div className="flex items-center gap-5 mb-12">
{user?.picture ? (
<Image
src={user.picture}
alt={displayName}
width={56}
height={56}
className="rounded-full object-cover"
style={{ width: 56, height: 56 }}
unoptimized
/>
) : (
<div className="w-14 h-14 rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-xl">
{(displayName)[0]?.toUpperCase() || "?"}
</div>
)}
<div>
<h1 className="text-2xl font-bold text-on-surface">{displayName}</h1>
<p className="text-on-surface-variant text-sm">Your Dashboard</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-8 border-b border-outline-variant">
<button
onClick={() => setActiveTab("submissions")}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
activeTab === "submissions"
? "border-primary text-primary"
: "border-transparent text-on-surface-variant hover:text-on-surface"
}`}
>
<FileText size={16} />
Submissions
</button>
<button
onClick={() => setActiveTab("profile")}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-colors ${
activeTab === "profile"
? "border-primary text-primary"
: "border-transparent text-on-surface-variant hover:text-on-surface"
}`}
>
<User size={16} />
Profile
</button>
</div>
{/* Submissions tab */}
{activeTab === "submissions" && (
<>
<section>
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-bold text-on-surface">Submit a Post</h2>
{!showForm && (
<Button
variant="primary"
size="sm"
onClick={() => {
setShowForm(true);
setFormSuccess("");
}}
>
<span className="flex items-center gap-2">
<Plus size={16} />
New Submission
</span>
</Button>
)}
</div>
{formSuccess && (
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm mb-6">
{formSuccess}
</div>
)}
{showForm && (
<form
onSubmit={handleSubmit}
className="bg-surface-container-low rounded-xl p-6 mb-8 space-y-4"
>
<p className="text-on-surface-variant text-sm mb-2">
Submit a Nostr longform post for moderator review. Provide the
event ID or naddr of the article you&apos;d like published on the
blog.
</p>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Title
</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..."
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>
{formError && (
<p className="text-error text-sm">{formError}</p>
)}
<div className="flex items-center gap-3 pt-2">
<Button
variant="primary"
size="md"
type="submit"
disabled={submitting}
>
<span className="flex items-center gap-2">
<Send size={16} />
{submitting ? "Submitting..." : "Submit for Review"}
</span>
</Button>
<Button
variant="secondary"
size="md"
type="button"
onClick={() => {
setShowForm(false);
setFormError("");
}}
>
Cancel
</Button>
</div>
</form>
)}
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-6">My Submissions</h2>
{loadingSubs ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse bg-surface-container-low rounded-xl p-6">
<div className="h-5 w-2/3 bg-surface-container-high rounded mb-3" />
<div className="h-4 w-1/3 bg-surface-container-high rounded" />
</div>
))}
</div>
) : submissions.length === 0 ? (
<div className="bg-surface-container-low rounded-xl p-8 text-center">
<FileText size={32} className="text-on-surface-variant/30 mx-auto mb-3" />
<p className="text-on-surface-variant/60 text-sm">
No submissions yet. Submit a Nostr longform post for review.
</p>
</div>
) : (
<div className="space-y-4">
{submissions.map((sub) => {
const statusCfg = STATUS_CONFIG[sub.status] || STATUS_CONFIG.PENDING;
const StatusIcon = statusCfg.icon;
return (
<div
key={sub.id}
className="bg-surface-container-low rounded-xl p-6"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-on-surface truncate">
{sub.title}
</h3>
<p className="text-on-surface-variant/60 text-xs mt-1">
{formatDate(sub.createdAt)}
{sub.eventId && (
<span className="ml-3 font-mono">
{sub.eventId.slice(0, 16)}...
</span>
)}
{sub.naddr && (
<span className="ml-3 font-mono">
{sub.naddr.slice(0, 20)}...
</span>
)}
</p>
</div>
<span
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap ${statusCfg.className}`}
>
<StatusIcon size={14} />
{statusCfg.label}
</span>
</div>
{sub.reviewNote && (
<p className="mt-3 text-sm text-on-surface-variant bg-surface-container-high rounded-lg px-4 py-2">
{sub.reviewNote}
</p>
)}
</div>
);
})}
</div>
)}
</section>
</>
)}
{/* Profile tab */}
{activeTab === "profile" && (
<section>
<h2 className="text-xl font-bold text-on-surface mb-2">NIP-05 Username</h2>
<p className="text-on-surface-variant text-sm mb-8">
Claim a NIP-05 verified Nostr address hosted on this site. Other Nostr
clients will display your identity as{" "}
<span className="font-mono text-on-surface">username@{hostname || "…"}</span>.
</p>
<form
onSubmit={handleSaveProfile}
className="bg-surface-container-low rounded-xl p-6 space-y-5 max-w-lg"
>
<div>
<label className="block text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2">
Username
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none">
<AtSign size={16} className="text-on-surface-variant/50" />
</div>
<input
type="text"
value={username}
onChange={(e) => handleUsernameChange(e.target.value)}
placeholder="yourname"
maxLength={50}
className="w-full bg-surface-container-highest text-on-surface rounded-lg pl-10 pr-10 py-3 font-mono text-sm placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
{usernameStatus.state === "checking" && (
<Loader2 size={16} className="animate-spin text-on-surface-variant/50" />
)}
{usernameStatus.state === "available" && (
<CheckCircle size={16} className="text-green-400" />
)}
{usernameStatus.state === "unavailable" && (
<XCircle size={16} className="text-error" />
)}
</div>
</div>
{/* Status message */}
<div className="mt-2 min-h-[20px]">
{usernameStatus.state === "checking" && (
<p className="text-xs text-on-surface-variant/60">Checking availability</p>
)}
{usernameStatus.state === "available" && (
<p className="text-xs text-green-400">Available</p>
)}
{usernameStatus.state === "unavailable" && (
<p className="text-xs text-error">{usernameStatus.reason}</p>
)}
</div>
</div>
{/* NIP-05 preview */}
{username.trim() && (
<div className="bg-surface-container-highest rounded-lg px-4 py-3">
<p className="text-xs text-on-surface-variant mb-1 uppercase tracking-widest font-bold">NIP-05 Address</p>
<p className="font-mono text-sm text-on-surface break-all">
{username.trim().toLowerCase()}@{hostname || "…"}
</p>
</div>
)}
{saveError && (
<p className="text-error text-sm">{saveError}</p>
)}
{saveSuccess && (
<div className="bg-green-400/10 text-green-400 rounded-lg px-4 py-3 text-sm">
{saveSuccess}
</div>
)}
<Button
variant="primary"
size="md"
type="submit"
disabled={isSaveDisabled}
>
{saving ? "Saving…" : "Save Username"}
</Button>
</form>
</section>
)}
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, MapPin, Clock, Calendar, ExternalLink } 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",
});
}
function DateBadge({ dateStr }: { dateStr: string }) {
const d = new Date(dateStr);
const month = d.toLocaleString("en-US", { month: "short" }).toUpperCase();
const day = String(d.getDate());
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">
{month}
</span>
<span className="block text-3xl font-black leading-none">{day}</span>
</div>
);
}
function EventSkeleton() {
return (
<div className="animate-pulse max-w-3xl mx-auto">
<div className="h-64 bg-zinc-800 rounded-2xl mb-10" />
<div className="h-8 w-3/4 bg-zinc-800 rounded mb-4" />
<div className="h-5 w-1/2 bg-zinc-800 rounded mb-8" />
<div className="space-y-3">
{[90, 80, 95, 70].map((w, i) => (
<div key={i} className="h-4 bg-zinc-800 rounded" style={{ width: `${w}%` }} />
))}
</div>
</div>
);
}
export default function EventDetailClient({ id }: { id: string }) {
const [meetup, setMeetup] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
setLoading(true);
api
.getMeetup(id)
.then(setMeetup)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
const isPast = meetup ? new Date(meetup.date) < new Date() : false;
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-12 pb-24">
<Link
href="/events"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-12 text-sm font-medium"
>
<ArrowLeft size={16} />
All Events
</Link>
{loading && <EventSkeleton />}
{error && (
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
Failed to load event: {error}
</div>
)}
{!loading && !error && meetup && (
<>
{meetup.imageId && (
<div className="rounded-2xl overflow-hidden mb-10 aspect-video bg-zinc-800">
<img
src={`/media/${meetup.imageId}`}
alt={meetup.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="flex items-start gap-5 mb-8">
<DateBadge dateStr={meetup.date} />
<div className="min-w-0">
{isPast && (
<span className="inline-block text-[10px] font-bold uppercase tracking-widest text-on-surface-variant/50 bg-zinc-800 px-2.5 py-1 rounded-full mb-3">
Past Event
</span>
)}
{!isPast && (
<span className="inline-block text-[10px] font-bold uppercase tracking-widest text-primary bg-primary/10 px-2.5 py-1 rounded-full mb-3">
Upcoming
</span>
)}
<h1 className="text-3xl md:text-4xl font-black tracking-tight leading-tight">
{meetup.title}
</h1>
</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)}
</div>
{meetup.time && (
<div className="flex items-center gap-2">
<Clock size={15} className="text-primary/70 shrink-0" />
{meetup.time}
</div>
)}
{meetup.location && (
<div className="flex items-center gap-2">
<MapPin size={15} className="text-primary/70 shrink-0" />
{meetup.location}
</div>
)}
</div>
{meetup.description && (
<div className="prose prose-invert max-w-none mb-12">
<p className="text-on-surface-variant leading-relaxed text-base whitespace-pre-wrap">
{meetup.description}
</p>
</div>
)}
{meetup.link && (
<a
href={meetup.link}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-primary text-on-primary px-8 py-4 rounded-xl font-bold text-sm hover:opacity-90 transition-opacity"
>
Register for this event <ExternalLink size={16} />
</a>
)}
</>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import EventDetailClient from "./EventDetailClient";
import { EventJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
async function fetchEvent(id: string) {
try {
const res = await fetch(`${apiUrl}/meetups/${id}`, {
next: { revalidate: 300 },
});
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
interface Props {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const event = await fetchEvent(id);
if (!event) {
return { title: "Event Not Found" };
}
const description =
event.description?.slice(0, 160) ||
`Bitcoin meetup: ${event.title}${event.location ? ` in ${event.location}` : ""}. Organized by the Belgian Bitcoin Embassy.`;
const ogImage = event.imageId
? `/media/${event.imageId}`
: `/og?title=${encodeURIComponent(event.title)}&type=event`;
return {
title: event.title,
description,
openGraph: {
type: "article",
title: event.title,
description,
images: [{ url: ogImage, width: 1200, height: 630, alt: event.title }],
},
twitter: {
card: "summary_large_image",
title: event.title,
description,
images: [ogImage],
},
alternates: { canonical: `/events/${id}` },
};
}
export default async function EventDetailPage({ params }: Props) {
const { id } = await params;
const event = await fetchEvent(id);
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
return (
<>
{event && (
<>
<EventJsonLd
name={event.title}
description={event.description}
startDate={event.date}
location={event.location}
url={`${siteUrl}/events/${id}`}
imageUrl={event.imageId ? `${siteUrl}/media/${event.imageId}` : undefined}
/>
<BreadcrumbJsonLd
items={[
{ name: "Home", href: "/" },
{ name: "Events", href: "/events" },
{ name: event.title, href: `/events/${id}` },
]}
/>
</>
)}
<EventDetailClient id={id} />
</>
);
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Events - Bitcoin Meetups in Belgium",
description:
"Browse upcoming and past Bitcoin meetups in Belgium organized by the Belgian Bitcoin Embassy. Monthly gatherings for education and community.",
openGraph: {
title: "Events - Belgian Bitcoin Embassy",
description:
"Upcoming and past Bitcoin meetups in Belgium. Join the community.",
},
alternates: { canonical: "/events" },
};
export default function EventsLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,190 @@
"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 { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
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",
}),
};
}
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) => new Date(m.date) >= now);
const past = meetups.filter((m) => new Date(m.date) < now).reverse();
return (
<>
<Navbar />
<div className="min-h-screen">
<header className="pt-24 pb-12 px-8">
<div className="max-w-6xl mx-auto">
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
Belgian Bitcoin Embassy
</p>
<h1 className="text-4xl md:text-6xl font-black tracking-tighter mb-4">
All Events
</h1>
<p className="text-on-surface-variant max-w-md leading-relaxed">
Past and upcoming Bitcoin meetups in Belgium.
</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>
<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>
{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 scheduled. Check back soon.
</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 />
</>
);
}

View File

@@ -0,0 +1,17 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "FAQ - Frequently Asked Questions",
description:
"Everything you need to know about the Belgian Bitcoin Embassy. Common questions about Bitcoin meetups, community, education, and how to get involved.",
openGraph: {
title: "FAQ - Belgian Bitcoin Embassy",
description:
"Answers to common questions about the Belgian Bitcoin Embassy.",
},
alternates: { canonical: "/faq" },
};
export default function FaqLayout({ children }: { children: React.ReactNode }) {
return children;
}

103
frontend/app/faq/page.tsx Normal file
View File

@@ -0,0 +1,103 @@
"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";
interface FaqItem {
id: string;
question: string;
answer: string;
order: number;
showOnHomepage: boolean;
}
export default function FaqPage() {
const [items, setItems] = useState<FaqItem[]>([]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getFaqsAll()
.then((data) => {
if (Array.isArray(data)) setItems(data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<>
{items.length > 0 && (
<FaqPageJsonLd items={items.map((i) => ({ question: i.question, answer: i.answer }))} />
)}
<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-4">Frequently Asked Questions</h1>
<p className="text-on-surface-variant text-lg mb-12">
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 && (
<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>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ArrowLeft, Download, Film } from "lucide-react";
import { api } from "@/lib/api";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
interface MediaItem {
id: string;
slug: string;
type: "image" | "video";
mimeType: string;
size: number;
originalFilename: string;
createdAt: string;
url: string;
title?: string;
description?: string;
altText?: string;
}
function extractUlid(slugParam: string): string {
const parts = slugParam.split("-");
return parts[parts.length - 1];
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export default function GalleryDetailPage() {
const { slug } = useParams<{ slug: string }>();
const [media, setMedia] = useState<MediaItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slug) return;
const id = extractUlid(slug);
api
.getMedia(id)
.then((data) => setMedia(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [slug]);
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-4xl mx-auto px-8 pt-12 pb-24">
<Link
href="/"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-12 text-sm font-medium"
>
<ArrowLeft size={16} />
Back
</Link>
{loading && (
<div className="flex items-center justify-center py-24">
<div className="text-on-surface/50">Loading...</div>
</div>
)}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
Media not found or failed to load.
</div>
)}
{!loading && !error && media && (
<div>
<div className="rounded-2xl overflow-hidden bg-surface-container-lowest mb-8">
{media.type === "image" ? (
<img
src={`/media/${media.id}`}
alt={media.altText || media.title || media.originalFilename}
className="w-full h-auto max-h-[80vh] object-contain mx-auto"
/>
) : (
<video
controls
src={`/media/${media.id}`}
className="w-full max-h-[80vh]"
>
Your browser does not support the video tag.
</video>
)}
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold text-on-surface mb-2">
{media.title || media.originalFilename}
</h1>
{media.description && (
<p className="text-on-surface-variant/70 text-sm mb-3 max-w-2xl">
{media.description}
</p>
)}
<div className="flex items-center gap-3 text-sm text-on-surface-variant/60">
<span className="flex items-center gap-1.5">
{media.type === "video" ? <Film size={14} /> : null}
{media.type.charAt(0).toUpperCase() + media.type.slice(1)}
</span>
<span>{formatFileSize(media.size)}</span>
<span>{media.mimeType}</span>
</div>
</div>
<a
href={`/media/${media.id}`}
download={media.originalFilename}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-container-highest transition-colors text-sm font-medium"
>
<Download size={16} />
Download
</a>
</div>
</div>
)}
</div>
</div>
<Footer />
</>
);
}

34
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,34 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
body {
background-color: #09090b;
color: #e5e2e1;
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
}
.glass-effect {
background: rgba(57, 57, 57, 0.4);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
}
.asymmetric-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
}
@media (max-width: 768px) {
.asymmetric-grid {
grid-template-columns: 1fr;
}
}
::selection {
background-color: #f7931a;
color: #603500;
}

92
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,92 @@
import type { Metadata, Viewport } from "next";
import { ClientProviders } from "@/components/providers/ClientProviders";
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/public/JsonLd";
import "./globals.css";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: "Belgian Bitcoin Embassy | Bitcoin Meetups & Education in Belgium",
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",
],
authors: [{ name: "Belgian Bitcoin Embassy" }],
creator: "Belgian Bitcoin Embassy",
publisher: "Belgian Bitcoin Embassy",
openGraph: {
type: "website",
locale: "en_BE",
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.",
images: [
{
url: "/og-default.png",
width: 1200,
height: 630,
alt: "Belgian Bitcoin Embassy - Bitcoin Meetups & Education in Belgium",
},
],
},
twitter: {
card: "summary_large_image",
title: "Belgian Bitcoin Embassy",
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
images: ["/og-default.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico", sizes: "32x32" },
],
apple: "/apple-touch-icon.png",
},
alternates: {
canonical: "/",
},
};
export const viewport: Viewport = {
themeColor: "#F7931A",
width: "device-width",
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" dir="ltr" className="dark">
<body>
<OrganizationJsonLd />
<WebSiteJsonLd />
<ClientProviders>{children}</ClientProviders>
</body>
</html>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Sign In",
description: "Sign in to the Belgian Bitcoin Embassy with your Nostr identity.",
robots: { index: false, follow: false },
};
export default function LoginLayout({ children }: { children: React.ReactNode }) {
return children;
}

292
frontend/app/login/page.tsx Normal file
View File

@@ -0,0 +1,292 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { LogIn, Puzzle, Smartphone, RefreshCw, Link2 } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import {
generateNostrConnectSetup,
waitForNostrConnectSigner,
} from "@/lib/nostr";
type Tab = "extension" | "external";
export default function LoginPage() {
const { user, loading, login, loginWithBunker, loginWithConnectedSigner } =
useAuth();
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>("extension");
const [error, setError] = useState("");
const [loggingIn, setLoggingIn] = useState(false);
// Extension tab
// (no extra state needed)
// External signer tab — QR section
const [qrUri, setQrUri] = useState<string | null>(null);
const [qrStatus, setQrStatus] = useState<
"generating" | "waiting" | "connecting"
>("generating");
const qrAbortRef = useRef<AbortController | null>(null);
const qrSecretRef = useRef<Uint8Array | null>(null);
// External signer tab — bunker URI section
const [bunkerInput, setBunkerInput] = useState("");
useEffect(() => {
if (!loading && user) redirectByRole(user.role);
}, [user, loading]);
function redirectByRole(role: string) {
if (role === "ADMIN" || role === "MODERATOR") {
router.push("/admin/overview");
} else {
router.push("/dashboard");
}
}
// Start (or restart) the nostrconnect QR flow
async function startQrFlow() {
qrAbortRef.current?.abort();
const controller = new AbortController();
qrAbortRef.current = controller;
setQrUri(null);
setQrStatus("generating");
setError("");
try {
const { uri, clientSecretKey } = await generateNostrConnectSetup();
if (controller.signal.aborted) return;
qrSecretRef.current = clientSecretKey;
setQrUri(uri);
setQrStatus("waiting");
const { signer } = await waitForNostrConnectSigner(
clientSecretKey,
uri,
controller.signal
);
if (controller.signal.aborted) return;
setQrStatus("connecting");
setLoggingIn(true);
const loggedInUser = await loginWithConnectedSigner(signer);
await signer.close().catch(() => {});
redirectByRole(loggedInUser.role);
} catch (err: any) {
if (controller.signal.aborted) return;
setError(err.message || "Connection failed");
setQrStatus("waiting");
setLoggingIn(false);
}
}
// Launch / restart QR when switching to external tab
useEffect(() => {
if (activeTab !== "external") {
qrAbortRef.current?.abort();
return;
}
startQrFlow();
return () => {
qrAbortRef.current?.abort();
};
}, [activeTab]);
const handleExtensionLogin = async () => {
setError("");
setLoggingIn(true);
try {
const loggedInUser = await login();
redirectByRole(loggedInUser.role);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setLoggingIn(false);
}
};
const handleBunkerLogin = async () => {
if (!bunkerInput.trim()) return;
setError("");
setLoggingIn(true);
try {
const loggedInUser = await loginWithBunker(bunkerInput.trim());
redirectByRole(loggedInUser.role);
} catch (err: any) {
setError(err.message || "Connection failed");
} finally {
setLoggingIn(false);
}
};
if (loading) {
return (
<>
<Navbar />
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-on-surface/50">Loading...</div>
</div>
<Footer />
</>
);
}
if (user) return null;
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: "extension", label: "Extension", icon: <Puzzle size={15} /> },
{ id: "external", label: "External Signer", icon: <Smartphone size={15} /> },
];
return (
<>
<Navbar />
<div className="flex items-center justify-center min-h-[70vh] px-8">
<div className="bg-surface-container-low rounded-xl p-8 max-w-md w-full">
{/* Header */}
<div className="text-center mb-6">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<LogIn size={26} className="text-primary" />
</div>
<h1 className="text-2xl font-bold text-on-surface mb-1">
Sign in to the Embassy
</h1>
<p className="text-on-surface/60 text-sm leading-relaxed">
Use your Nostr identity to access your dashboard.
</p>
</div>
{/* Tab bar */}
<div className="flex rounded-lg bg-surface-container p-1 mb-6 gap-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id);
setError("");
}}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-md text-sm font-medium transition-all ${
activeTab === tab.id
? "bg-surface-container-high text-on-surface shadow-sm"
: "text-on-surface/50 hover:text-on-surface/80"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Extension tab */}
{activeTab === "extension" && (
<div className="space-y-4">
<button
onClick={handleExtensionLogin}
disabled={loggingIn}
className="w-full flex items-center justify-center gap-3 px-6 py-3.5 rounded-lg font-semibold transition-all bg-gradient-to-r from-primary to-primary-container text-on-primary hover:scale-105 active:opacity-80 disabled:opacity-50 disabled:hover:scale-100"
>
<LogIn size={20} />
{loggingIn ? "Connecting..." : "Login with Nostr"}
</button>
<p className="text-on-surface/40 text-xs text-center leading-relaxed">
Requires a Nostr browser extension such as Alby, nos2x, or
Flamingo. Your keys never leave your device.
</p>
</div>
)}
{/* External signer tab */}
{activeTab === "external" && (
<div className="space-y-5">
{/* QR section */}
<div className="rounded-lg bg-surface-container p-4 flex flex-col items-center gap-3">
{qrStatus === "generating" || !qrUri ? (
<div className="w-[200px] h-[200px] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : (
<div className="p-2 bg-white rounded-lg">
<QRCodeSVG value={qrUri} size={192} />
</div>
)}
<div className="text-center">
{qrStatus === "generating" && (
<p className="text-on-surface/50 text-xs">
Generating QR code
</p>
)}
{qrStatus === "waiting" && (
<p className="text-on-surface/60 text-xs">
Scan with your signer app (e.g.{" "}
<span className="text-primary font-medium">Amber</span>)
</p>
)}
{qrStatus === "connecting" && (
<p className="text-on-surface/60 text-xs">
Signer connected signing in
</p>
)}
</div>
{qrStatus === "waiting" && qrUri && (
<button
onClick={() => startQrFlow()}
className="flex items-center gap-1.5 text-xs text-on-surface/40 hover:text-on-surface/70 transition-colors"
>
<RefreshCw size={12} />
Refresh QR
</button>
)}
</div>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-on-surface/10" />
<span className="text-on-surface/30 text-xs">or</span>
<div className="flex-1 h-px bg-on-surface/10" />
</div>
{/* Bunker URI input */}
<div className="space-y-2">
<label className="text-xs font-medium text-on-surface/60 flex items-center gap-1.5">
<Link2 size={12} />
Bunker URL
</label>
<input
type="text"
value={bunkerInput}
onChange={(e) => setBunkerInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleBunkerLogin()}
placeholder="bunker://..."
disabled={loggingIn}
className="w-full bg-surface-container rounded-lg px-3 py-2.5 text-sm text-on-surface placeholder:text-on-surface/30 border border-on-surface/10 focus:outline-none focus:border-primary/50 disabled:opacity-50"
/>
<button
onClick={handleBunkerLogin}
disabled={loggingIn || !bunkerInput.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
{loggingIn ? "Connecting..." : "Connect"}
</button>
</div>
</div>
)}
{/* Shared error */}
{error && (
<p className="mt-4 text-error text-sm text-center">{error}</p>
)}
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,178 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH
? path.resolve(process.env.MEDIA_STORAGE_PATH)
: path.resolve(process.cwd(), '../storage/media');
const CACHE_PATH = path.join(STORAGE_PATH, 'cache');
const CACHE_HEADERS = {
'Cache-Control': 'public, max-age=31536000, immutable',
};
interface MediaMeta {
mimeType: string;
type: 'image' | 'video';
size: number;
}
function readMeta(id: string): MediaMeta | null {
const metaPath = path.join(STORAGE_PATH, `${id}.json`);
try {
const raw = fs.readFileSync(metaPath, 'utf-8');
return JSON.parse(raw);
} catch {
return null;
}
}
function fileExists(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
async function handleImageResize(
filePath: string,
width: number,
meta: MediaMeta,
id: string
): Promise<NextResponse> {
fs.mkdirSync(CACHE_PATH, { recursive: true });
const cacheKey = `${id}_w${width}`;
const cachedPath = path.join(CACHE_PATH, cacheKey);
if (fileExists(cachedPath)) {
const cached = fs.readFileSync(cachedPath);
return new NextResponse(new Uint8Array(cached), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(cached.length),
...CACHE_HEADERS,
},
});
}
const buffer = fs.readFileSync(filePath);
const resized = await sharp(buffer)
.resize({ width, withoutEnlargement: true })
.toBuffer();
fs.writeFileSync(cachedPath, resized);
return new NextResponse(new Uint8Array(resized), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(resized.length),
...CACHE_HEADERS,
},
});
}
function handleVideoStream(
filePath: string,
meta: MediaMeta,
rangeHeader: string | null
): NextResponse {
const stat = fs.statSync(filePath);
const fileSize = stat.size;
if (rangeHeader) {
const parts = rangeHeader.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, { start, end });
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(readable as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(chunkSize),
'Content-Type': meta.mimeType,
...CACHE_HEADERS,
},
});
}
const stream = fs.createReadStream(filePath);
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(readable as any, {
status: 200,
headers: {
'Accept-Ranges': 'bytes',
'Content-Length': String(fileSize),
'Content-Type': meta.mimeType,
...CACHE_HEADERS,
},
});
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const { id } = params;
const filePath = path.join(STORAGE_PATH, id);
if (!fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const meta = readMeta(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 handleImageResize(filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
// Full image, no resize
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,
},
});
}

View File

@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Page Not Found",
robots: { index: false, follow: false },
};
export default function NotFound() {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-8">
<span className="text-8xl md:text-[12rem] font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-primary to-primary-container leading-none">
404
</span>
<h1 className="text-2xl md:text-3xl font-bold mt-6 mb-3">
Page not found
</h1>
<p className="text-on-surface-variant mb-10 text-center max-w-md">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/"
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"
>
Back to Home
</Link>
</div>
);
}

152
frontend/app/og/route.tsx Normal file
View File

@@ -0,0 +1,152 @@
import { ImageResponse } from "next/og";
import { type NextRequest } from "next/server";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const title = searchParams.get("title") || "Belgian Bitcoin Embassy";
const type = searchParams.get("type") || "default";
const subtitle =
searchParams.get("subtitle") ||
(type === "blog"
? "Blog"
: type === "event"
? "Event"
: "Bitcoin Meetups & Education in Belgium");
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
background: "linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)",
fontFamily: "system-ui, sans-serif",
padding: "60px 80px",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "4px",
background: "#F7931A",
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "24px",
maxWidth: "1000px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "16px",
marginBottom: "8px",
}}
>
<div
style={{
width: "48px",
height: "48px",
borderRadius: "12px",
background: "#F7931A",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "28px",
fontWeight: 700,
color: "#fff",
}}
>
B
</div>
<span
style={{
fontSize: "14px",
fontWeight: 600,
color: "#F7931A",
letterSpacing: "4px",
textTransform: "uppercase",
}}
>
{subtitle}
</span>
</div>
<h1
style={{
fontSize: title.length > 60 ? "40px" : title.length > 40 ? "48px" : "56px",
fontWeight: 800,
color: "#ffffff",
textAlign: "center",
lineHeight: 1.15,
margin: 0,
letterSpacing: "-1px",
}}
>
{title}
</h1>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginTop: "16px",
}}
>
<span
style={{
fontSize: "16px",
color: "#666",
fontWeight: 500,
}}
>
belgianbitcoinembassy.org
</span>
</div>
</div>
<div
style={{
position: "absolute",
bottom: "40px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span
style={{
fontSize: "13px",
fontWeight: 500,
color: "#F7931A",
letterSpacing: "4px",
textTransform: "uppercase",
}}
>
No hype, just signal
</span>
</div>
</div>
),
{
width: 1200,
height: 630,
},
);
}

72
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,72 @@
"use client";
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";
import { FAQSection } from "@/components/public/FAQSection";
import { FinalCTASection } from "@/components/public/FinalCTASection";
import { Footer } from "@/components/public/Footer";
import { api } from "@/lib/api";
export default function HomePage() {
const [meetup, setMeetup] = useState<any>(null);
const [allMeetups, setAllMeetups] = useState<any[]>([]);
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
api.getMeetups()
.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
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());
setAllMeetups(upcoming);
if (upcoming.length > 0) setMeetup(upcoming[0]);
})
.catch(() => {});
api.getPublicSettings()
.then((data) => setSettings(data))
.catch(() => {});
}, []);
const meetupProps = meetup
? {
id: meetup.id,
month: new Date(meetup.date).toLocaleString("en-US", { month: "short" }),
day: String(new Date(meetup.date).getDate()),
title: meetup.title,
location: meetup.location,
time: meetup.time,
link: meetup.link || "#meetup",
}
: undefined;
return (
<main>
<Navbar />
<section id="meetup">
<HeroSection meetup={meetupProps} />
</section>
<section id="about">
<AboutSection />
</section>
<KnowledgeCards />
<CommunityLinksSection settings={settings} />
<section id="upcoming-meetups">
<MeetupsSection meetups={allMeetups} />
</section>
<section id="faq">
<FAQSection />
</section>
<FinalCTASection telegramLink={settings.telegram_link} />
<Footer />
</main>
);
}

View File

@@ -0,0 +1,78 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
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.",
openGraph: {
title: "Privacy Policy - Belgian Bitcoin Embassy",
description: "How we handle your data. Minimal collection, no tracking, full transparency.",
},
alternates: { canonical: "/privacy" },
};
export default function PrivacyPage() {
return (
<>
<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>
<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>
<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.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Data We Collect</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.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Nostr Interactions</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.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">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.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Contact</h2>
<p>
For privacy-related questions, reach out to us via our{" "}
<Link href="/#community" className="text-primary hover:underline">
community channels
</Link>.
</p>
</section>
</div>
</div>
</div>
<Footer />
</>
);
}

17
frontend/app/robots.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin", "/admin/", "/dashboard", "/dashboard/", "/login"],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
};
}

56
frontend/app/sitemap.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { MetadataRoute } from "next";
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
async function fetchJson<T>(path: string): Promise<T | null> {
try {
const res = await fetch(`${apiUrl}${path}`, { next: { revalidate: 3600 } });
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticRoutes: MetadataRoute.Sitemap = [
{ url: siteUrl, lastModified: new Date(), changeFrequency: "weekly", priority: 1.0 },
{ url: `${siteUrl}/blog`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
{ url: `${siteUrl}/events`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.9 },
{ url: `${siteUrl}/community`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.7 },
{ url: `${siteUrl}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 },
{ url: `${siteUrl}/faq`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.6 },
{ url: `${siteUrl}/privacy`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
{ url: `${siteUrl}/terms`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
];
const blogRoutes: MetadataRoute.Sitemap = [];
const postsData = await fetchJson<{ posts: any[]; total: number }>("/posts?limit=500");
if (postsData?.posts) {
for (const post of postsData.posts) {
blogRoutes.push({
url: `${siteUrl}/blog/${post.slug}`,
lastModified: post.updatedAt || post.publishedAt || post.createdAt,
changeFrequency: "weekly",
priority: 0.8,
});
}
}
const eventRoutes: MetadataRoute.Sitemap = [];
const meetups = await fetchJson<any[]>("/meetups");
if (Array.isArray(meetups)) {
for (const meetup of meetups) {
eventRoutes.push({
url: `${siteUrl}/events/${meetup.id}`,
lastModified: meetup.updatedAt || meetup.createdAt,
changeFrequency: "weekly",
priority: 0.7,
});
}
}
return [...staticRoutes, ...blogRoutes, ...eventRoutes];
}

View File

@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
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.",
openGraph: {
title: "Terms of Use - Belgian Bitcoin Embassy",
description: "Terms governing the use of the Belgian Bitcoin Embassy platform.",
},
alternates: { canonical: "/terms" },
};
export default function TermsPage() {
return (
<>
<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>
<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>
<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.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Content</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.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">No Financial Advice</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.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">User Conduct</h2>
<p>
Users interacting via Nostr (likes, comments) are expected to behave
respectfully. The moderation team reserves the right to locally hide
content or block pubkeys that violate community standards.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-on-surface mb-4">Liability</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.
</p>
</section>
</div>
</div>
</div>
<Footer />
</>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Calendar,
FileText,
Shield,
Tag,
Users,
Radio,
Settings,
Wrench,
LogOut,
ArrowLeft,
Inbox,
ImageIcon,
HelpCircle,
} 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 },
];
export function AdminSidebar() {
const pathname = usePathname();
const { user, logout, isAdmin } = 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">
<Link href="/" className="text-primary-container font-bold text-xl">
BBE Admin
</Link>
</div>
{!user ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-on-surface/40 text-sm text-center">
Please log in to access the dashboard.
</p>
</div>
) : (
<>
<div className="mb-6">
<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"
? "bg-primary-container/20 text-primary"
: "bg-secondary-container text-on-secondary-container"
)}
>
{user.role}
</span>
</div>
<nav className="flex-1 space-y-1">
{navItems
.filter((item) => !item.adminOnly || isAdmin)
.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg transition-colors",
active
? "bg-surface-container-high text-primary"
: "text-on-surface/70 hover:text-on-surface hover:bg-surface-container"
)}
>
<Icon size={20} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="mt-auto 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"
>
<LogOut size={20} />
<span>Logout</span>
</button>
<Link
href="/"
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"
>
<ArrowLeft size={20} />
<span>Back to site</span>
</Link>
</div>
</>
)}
</aside>
);
}

View File

@@ -0,0 +1,155 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { X, Upload, Film, Check } from "lucide-react";
interface MediaItem {
id: string;
slug: string;
type: "image" | "video";
mimeType: string;
size: number;
originalFilename: string;
url: string;
}
interface MediaPickerModalProps {
onSelect: (mediaId: string) => void;
onClose: () => void;
selectedId?: string | null;
}
export function MediaPickerModal({ onSelect, onClose, selectedId }: MediaPickerModalProps) {
const [media, setMedia] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const loadMedia = async () => {
try {
const data = await api.getMediaList();
setMedia(data.filter((m: MediaItem) => m.type === "image"));
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMedia();
}, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setError("");
try {
const result = await api.uploadMedia(file);
await loadMedia();
onSelect(result.id);
} catch (err: any) {
setError(err.message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
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 className="relative bg-surface-container-low rounded-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
<div className="flex items-center justify-between p-5 border-b border-surface-container-highest">
<h2 className="text-lg font-semibold text-on-surface">Select Image</h2>
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-xs hover:opacity-90 transition-opacity disabled:opacity-50"
>
<Upload size={14} />
{uploading ? "Uploading..." : "Upload New"}
</button>
<button
onClick={onClose}
className="text-on-surface/50 hover:text-on-surface transition-colors"
>
<X size={20} />
</button>
</div>
</div>
{error && <p className="text-error text-sm px-5 pt-3">{error}</p>}
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-on-surface/50 text-sm">Loading media...</p>
</div>
) : media.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-on-surface/50 text-sm">No images available.</p>
<p className="text-on-surface/30 text-xs mt-1">Upload an image to get started.</p>
</div>
) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
{media.map((item) => (
<button
key={item.id}
onClick={() => onSelect(item.id)}
className={cn(
"relative aspect-square rounded-lg overflow-hidden border-2 transition-all hover:border-primary/60",
selectedId === item.id
? "border-primary ring-2 ring-primary/30"
: "border-transparent"
)}
>
{item.type === "image" ? (
<img
src={`/media/${item.id}?w=200`}
alt={item.originalFilename}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full bg-surface-container-highest flex items-center justify-center">
<Film size={24} className="text-on-surface/30" />
</div>
)}
{selectedId === item.id && (
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center">
<Check size={16} className="text-on-primary" />
</div>
</div>
)}
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
import { ReactNode } from "react";
import { AuthContext, useAuthProvider } from "@/hooks/useAuth";
export function AuthProvider({ children }: { children: ReactNode }) {
const auth = useAuthProvider();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}

View File

@@ -0,0 +1,8 @@
"use client";
import { ReactNode } from "react";
import { AuthProvider } from "./AuthProvider";
export function ClientProviders({ children }: { children: ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}

View File

@@ -0,0 +1,23 @@
export function AboutSection() {
return (
<section id="about" className="py-32 px-8">
<div className="max-w-5xl mx-auto text-center">
<span className="uppercase tracking-[0.3em] text-primary mb-8 block text-sm font-semibold">
The Mission
</span>
<h2 className="text-4xl md:text-5xl font-black mb-10 leading-tight">
&ldquo;Fix the money, fix the world.&rdquo;
</h2>
<p className="text-2xl text-on-surface-variant font-light leading-relaxed mb-12">
We help people in Belgium understand and adopt Bitcoin through
education, meetups, and community. We are not a company, but a
sovereign network of individuals building a sounder future.
</p>
<div className="w-24 h-1 bg-primary mx-auto opacity-50" />
</div>
</section>
);
}

View File

@@ -0,0 +1,81 @@
import { ArrowRight } from "lucide-react";
import Link from "next/link";
interface BlogPost {
slug: string;
title: string;
excerpt: string;
categories: string[];
}
interface BlogPreviewSectionProps {
posts?: BlogPost[];
}
export function BlogPreviewSection({ posts }: BlogPreviewSectionProps) {
return (
<section className="py-24 px-8 border-t border-zinc-800/50">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-end mb-12">
<div>
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
From the network
</p>
<h2 className="text-3xl font-black tracking-tight">Latest from the Blog</h2>
</div>
<Link
href="/blog"
className="hidden md:flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
>
View All <ArrowRight size={16} />
</Link>
</div>
{!posts || posts.length === 0 ? (
<p className="text-on-surface-variant text-center py-16 text-sm">
No posts yet. Check back soon for curated Bitcoin content.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{posts.map((post) => (
<Link
key={post.slug}
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.length > 0 && (
<div className="flex flex-wrap gap-2 mb-4">
{post.categories.map((cat) => (
<span
key={cat}
className="text-primary text-[10px] uppercase tracking-widest font-bold"
>
{cat}
</span>
))}
</div>
)}
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
{post.title}
</h3>
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
{post.excerpt}
</p>
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all mt-auto">
Read More <ArrowRight size={13} />
</span>
</Link>
))}
</div>
)}
<Link
href="/blog"
className="md:hidden flex items-center justify-center gap-2 text-primary font-semibold mt-8 text-sm"
>
View All <ArrowRight size={16} />
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,169 @@
import { type SVGProps } from "react";
interface PlatformDef {
name: string;
description: string;
settingKey: string;
Icon: (props: SVGProps<SVGSVGElement>) => JSX.Element;
}
function IconTelegram(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
</svg>
);
}
function IconNostr(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);
}
function IconX(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
function IconYouTube(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M22.54 6.42a2.78 2.78 0 00-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 00-1.94 2A29 29 0 001 11.75a29 29 0 00.46 5.33 2.78 2.78 0 001.94 2c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 001.94-2 29 29 0 00.46-5.33 29 29 0 00-.46-5.33z" />
<path d="M9.75 15.02l5.75-3.27-5.75-3.27v6.54z" />
</svg>
);
}
function IconDiscord(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.028zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
function IconLinkedIn(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zM7.119 20.452H3.554V9h3.565v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
);
}
function IconArrowOut(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M7 17l9.2-9.2M17 17V7H7" />
</svg>
);
}
const PLATFORMS: PlatformDef[] = [
{
name: "Telegram",
description: "Join the main Belgian chat group for daily discussion and local coordination.",
settingKey: "telegram_link",
Icon: IconTelegram,
},
{
name: "Nostr",
description: "Follow the BBE on the censorship-resistant social protocol for true signal.",
settingKey: "nostr_link",
Icon: IconNostr,
},
{
name: "X",
description: "Stay updated with our latest local announcements and event drops.",
settingKey: "x_link",
Icon: IconX,
},
{
name: "YouTube",
description: "Watch past talks, educational content, and high-quality BBE meetup recordings.",
settingKey: "youtube_link",
Icon: IconYouTube,
},
{
name: "Discord",
description: "Deep dive into technical discussions, node running, and project collaboration.",
settingKey: "discord_link",
Icon: IconDiscord,
},
{
name: "LinkedIn",
description: "Connect with the Belgian Bitcoin professional network and industry leaders.",
settingKey: "linkedin_link",
Icon: IconLinkedIn,
},
];
interface CommunityLinksSectionProps {
settings?: Record<string, string>;
}
export function CommunityLinksSection({ settings = {} }: CommunityLinksSectionProps) {
return (
<section
id="community"
className="relative py-16 sm:py-20 px-4 sm:px-8"
>
<div className="max-w-[1100px] mx-auto w-full">
<header className="text-center mb-8 sm:mb-10">
<h2 className="text-[1.75rem] sm:text-4xl font-extrabold tracking-tight text-white mb-2">
Join the <span className="text-[#F7931A]">community</span>
</h2>
<p className="text-zinc-400 text-sm sm:text-[0.95rem] max-w-[550px] mx-auto leading-relaxed">
Connect with local Belgian Bitcoiners, builders, and educators.
</p>
</header>
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
{PLATFORMS.map((platform) => {
const href = settings[platform.settingKey] || "#";
const isExternal = href.startsWith("http");
const Icon = platform.Icon;
return (
<a
key={platform.name}
href={href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noopener noreferrer" : undefined}
className="group relative flex flex-col rounded-xl border border-zinc-800 bg-zinc-900 p-5 no-underline overflow-hidden transition-all duration-300 hover:-translate-y-[3px] hover:border-[rgba(247,147,26,0.4)] hover:shadow-[0_10px_25px_-10px_rgba(0,0,0,0.5)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#F7931A]"
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-[radial-gradient(circle_at_top_right,rgba(247,147,26,0.08),transparent_60%)]"
/>
<div className="relative z-[1] flex items-center justify-between mb-3">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-zinc-800 bg-black text-[#F7931A] transition-all duration-300 group-hover:scale-105 group-hover:-rotate-[5deg] group-hover:border-[#F7931A] group-hover:bg-[#F7931A] group-hover:text-black">
<Icon className="h-[18px] w-[18px] shrink-0" aria-hidden />
</span>
<h3 className="text-[1.05rem] font-bold text-white truncate transition-colors duration-300 group-hover:text-[#F7931A]">
{platform.name}
</h3>
</div>
<IconArrowOut
className="h-[18px] w-[18px] shrink-0 text-zinc-600 transition-all duration-300 group-hover:text-[#F7931A] group-hover:translate-x-[3px] group-hover:-translate-y-[3px]"
aria-hidden
/>
</div>
<p className="relative z-[1] text-[0.85rem] leading-relaxed text-zinc-400">
{platform.description}
</p>
</a>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
interface FaqItem {
id: string;
question: string;
answer: string;
order: number;
showOnHomepage: boolean;
}
export function FAQSection() {
const [items, setItems] = useState<FaqItem[]>([]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
useEffect(() => {
api.getFaqs().catch(() => []).then((data) => {
if (Array.isArray(data)) setItems(data);
});
}, []);
if (items.length === 0) return null;
return (
<section id="faq" className="py-24 px-8">
<div className="max-w-3xl mx-auto">
<h2 className="text-4xl font-black mb-16 text-center">
Frequently Asked Questions
</h2>
<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>
</div>
</section>
);
}

View File

@@ -0,0 +1,44 @@
import { Send, Bitcoin } from "lucide-react";
import { Button } from "@/components/ui/Button";
interface FinalCTASectionProps {
telegramLink?: string;
}
export function FinalCTASection({ telegramLink }: FinalCTASectionProps) {
return (
<section className="py-32 px-8 bg-surface-container-low relative overflow-hidden">
<div className="max-w-4xl mx-auto text-center relative z-10">
<h2 className="text-5xl font-black mb-8">
Join us
</h2>
<p className="text-on-surface-variant text-xl mb-12">
The best time to learn was 10 years ago. The second best time is
today. Join the community.
</p>
<div className="flex flex-col md:flex-row items-center justify-center gap-6">
<a
href={telegramLink || "#community"}
target={telegramLink ? "_blank" : undefined}
rel={telegramLink ? "noopener noreferrer" : undefined}
>
<Button variant="telegram" size="lg" className="w-full md:w-auto flex items-center justify-center gap-2">
<Send size={18} /> Join Telegram
</Button>
</a>
<a href="/events">
<Button variant="primary" size="lg" className="w-full md:w-auto">
Attend Meetup
</Button>
</a>
</div>
</div>
<Bitcoin
size={400}
className="absolute -bottom-20 -right-20 opacity-5 text-on-surface"
/>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import Link from "next/link";
const LINKS = [
{ label: "FAQ", href: "/faq" },
{ label: "Community", href: "/community" },
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
{ label: "Contact", href: "/contact" },
];
export function Footer() {
return (
<footer className="w-full py-12 bg-surface-container-lowest">
<div className="flex flex-col items-center justify-center space-y-6 w-full px-8 text-center">
<Link href="/" className="text-lg font-black text-primary-container">
Belgian Bitcoin Embassy
</Link>
<nav aria-label="Footer navigation" className="flex space-x-12">
{LINKS.map((link) => (
<Link
key={link.label}
href={link.href}
className="text-white opacity-50 hover:opacity-100 transition-opacity text-sm tracking-widest uppercase"
>
{link.label}
</Link>
))}
</nav>
<p className="text-white opacity-50 text-sm tracking-widest uppercase">
&copy; Belgian Bitcoin Embassy. No counterparty risk.
</p>
</div>
</footer>
);
}

View File

@@ -0,0 +1,71 @@
import { ArrowRight, MapPin } from "lucide-react";
import Link from "next/link";
interface MeetupData {
id?: string;
month?: string;
day?: string;
title?: string;
location?: string;
time?: string;
link?: string;
}
interface HeroSectionProps {
meetup?: MeetupData;
}
export function HeroSection({ meetup }: HeroSectionProps) {
const month = meetup?.month ?? "TBD";
const day = meetup?.day ?? "--";
const title = meetup?.title ?? "Next Gathering";
const location = meetup?.location ?? "Brussels, BE";
const time = meetup?.time ?? "19:00";
const eventHref = meetup?.id ? `/events/${meetup.id}` : "#meetup";
return (
<section className="pt-32 pb-24 px-8">
<div className="max-w-4xl mx-auto text-center">
<span className="inline-block uppercase tracking-[0.25em] text-primary mb-8 font-semibold text-xs border border-primary/20 px-4 py-1.5 rounded-full">
Antwerp, Belgium
</span>
<h1 className="text-5xl md:text-7xl font-black tracking-tighter leading-[0.95] mb-6">
Belgium&apos;s Monthly
<br />
<span className="text-primary">Bitcoin Meetups</span>
</h1>
<p className="text-lg text-on-surface-variant max-w-md mx-auto leading-relaxed mb-14">
A sovereign space for education, technical discussion, and community.
No hype, just signal.
</p>
<div className="inline-flex flex-col sm:flex-row items-stretch sm:items-center gap-4 bg-zinc-900 border border-zinc-800 rounded-2xl p-4 sm:p-5 w-full max-w-xl">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="bg-zinc-800 rounded-xl px-3 py-2 text-center shrink-0 min-w-[52px]">
<span className="block text-[10px] font-bold uppercase text-primary tracking-wider leading-none mb-0.5">
{month}
</span>
<span className="block text-2xl font-black leading-none">{day}</span>
</div>
<div className="text-left min-w-0">
<p className="font-bold text-base truncate">{title}</p>
<p className="text-on-surface-variant text-sm flex items-center gap-1 mt-0.5">
<MapPin size={12} className="shrink-0" />
<span className="truncate">{location} · {time}</span>
</p>
</div>
</div>
<Link
href={eventHref}
className="flex items-center justify-center gap-2 bg-primary text-on-primary px-6 py-3 rounded-xl font-bold text-sm hover:opacity-90 transition-opacity shrink-0"
>
More info <ArrowRight size={16} />
</Link>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,199 @@
interface JsonLdProps {
data: Record<string, unknown>;
}
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
export function OrganizationJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
logo: `${siteUrl}/og-default.png`,
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
sameAs: ["https://t.me/belgianbitcoinembassy"],
address: {
"@type": "PostalAddress",
addressLocality: "Antwerp",
addressCountry: "BE",
},
}}
/>
);
}
export function WebSiteJsonLd() {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "WebSite",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
description:
"Belgium's sovereign Bitcoin community. Monthly meetups, education, and curated Nostr content.",
publisher: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
logo: { "@type": "ImageObject", url: `${siteUrl}/og-default.png` },
},
}}
/>
);
}
interface BlogPostingJsonLdProps {
title: string;
description: string;
slug: string;
publishedAt?: string;
authorName?: string;
}
export function BlogPostingJsonLd({
title,
description,
slug,
publishedAt,
authorName,
}: BlogPostingJsonLdProps) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: title,
description,
url: `${siteUrl}/blog/${slug}`,
...(publishedAt ? { datePublished: publishedAt } : {}),
author: {
"@type": "Person",
name: authorName || "Belgian Bitcoin Embassy",
},
publisher: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
logo: { "@type": "ImageObject", url: `${siteUrl}/og-default.png` },
},
image: `${siteUrl}/og?title=${encodeURIComponent(title)}&type=blog`,
mainEntityOfPage: {
"@type": "WebPage",
"@id": `${siteUrl}/blog/${slug}`,
},
}}
/>
);
}
interface EventJsonLdProps {
name: string;
description?: string;
startDate: string;
location?: string;
url: string;
imageUrl?: string;
}
export function EventJsonLd({
name,
description,
startDate,
location,
url,
imageUrl,
}: EventJsonLdProps) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Event",
name,
description: description || `Bitcoin meetup: ${name}`,
startDate,
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
eventStatus: "https://schema.org/EventScheduled",
...(location
? {
location: {
"@type": "Place",
name: location,
address: {
"@type": "PostalAddress",
addressLocality: location,
addressCountry: "BE",
},
},
}
: {}),
organizer: {
"@type": "Organization",
name: "Belgian Bitcoin Embassy",
url: siteUrl,
},
image:
imageUrl || `${siteUrl}/og?title=${encodeURIComponent(name)}&type=event`,
url,
}}
/>
);
}
interface FaqJsonLdProps {
items: { question: string; answer: string }[];
}
export function FaqPageJsonLd({ items }: FaqJsonLdProps) {
if (items.length === 0) return null;
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: items.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: {
"@type": "Answer",
text: item.answer,
},
})),
}}
/>
);
}
interface BreadcrumbItem {
name: string;
href: string;
}
export function BreadcrumbJsonLd({ items }: { items: BreadcrumbItem[] }) {
return (
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: `${siteUrl}${item.href}`,
})),
}}
/>
);
}

View File

@@ -0,0 +1,49 @@
import { Landmark, Infinity, Key } from "lucide-react";
const CARDS = [
{
icon: Landmark,
title: "Money without banks",
description:
"Operate outside the legacy financial system with peer-to-peer digital sound money.",
},
{
icon: Infinity,
title: "Scarcity: 21 million",
description:
"A mathematical certainty of fixed supply. No inflation, no dilution, ever.",
},
{
icon: Key,
title: "Self-custody",
description:
"True ownership. Your keys, your bitcoin. No counterparty risk, absolute freedom.",
},
];
export function KnowledgeCards() {
return (
<section className="py-16 px-8 border-t border-zinc-800/50">
<div className="max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{CARDS.map((card) => (
<div
key={card.title}
className="flex gap-4 p-6 rounded-xl bg-zinc-900/60 border border-zinc-800/60"
>
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<card.icon size={16} className="text-primary" />
</div>
<div>
<h4 className="font-bold mb-1.5 text-sm">{card.title}</h4>
<p className="text-on-surface-variant text-sm leading-relaxed">
{card.description}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,137 @@
import { MapPin, Clock, ArrowRight, CalendarPlus } from "lucide-react";
import Link from "next/link";
interface MeetupData {
id?: string;
title: string;
date: string;
time?: string;
location?: string;
link?: string;
description?: string;
}
interface MeetupsSectionProps {
meetups: MeetupData[];
}
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" }),
};
}
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
return (
<section className="py-24 px-8 border-t border-zinc-800/50">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-end mb-12">
<div>
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
Mark your calendar
</p>
<h2 className="text-3xl font-black tracking-tight">Upcoming Meetups</h2>
</div>
<div className="hidden md:flex items-center gap-4">
<a
href="/calendar.ics"
title="Subscribe to get all future meetups automatically"
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
</a>
<Link
href="/events"
className="flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
>
All events <ArrowRight size={16} />
</Link>
</div>
</div>
{meetups.length === 0 ? (
<div className="border border-zinc-800 rounded-xl px-8 py-12 text-center">
<p className="text-on-surface-variant text-sm">
No upcoming meetups scheduled. Check back soon.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{meetups.map((meetup, i) => {
const { month, day, full } = formatMeetupDate(meetup.date);
const href = meetup.id ? `/events/${meetup.id}` : "#upcoming-meetups";
return (
<Link
key={meetup.id ?? i}
href={href}
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"
>
<div className="flex items-start gap-4 mb-4">
<div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]">
<span className="block text-[10px] font-bold uppercase text-primary tracking-wider leading-none mb-0.5">
{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 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 text-primary/60" />
{meetup.time}
</p>
)}
</div>
<span className="flex items-center gap-1.5 text-primary text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all">
View Details <ArrowRight size={12} />
</span>
</Link>
);
})}
</div>
)}
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
<Link
href="/events"
className="flex items-center gap-2 text-primary font-semibold text-sm"
>
All events <ArrowRight size={16} />
</Link>
<a
href="/calendar.ics"
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
</a>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,300 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Menu, X, LogIn, User, LayoutDashboard, LogOut, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/Button";
import { useAuth } from "@/hooks/useAuth";
import { shortenPubkey } from "@/lib/nostr";
const SECTION_LINKS = [{ label: "About", anchor: "about" }];
const PAGE_LINKS = [
{ label: "Meetups", href: "/events" },
{ label: "Community", href: "/community" },
{ label: "FAQ", href: "/faq" },
];
function ProfileAvatar({
picture,
name,
size = 36,
}: {
picture?: string;
name?: string;
size?: number;
}) {
const [imgError, setImgError] = useState(false);
const initial = (name || "?")[0].toUpperCase();
if (picture && !imgError) {
return (
<Image
src={picture}
alt={name || "Profile"}
width={size}
height={size}
className="rounded-full object-cover"
style={{ width: size, height: size }}
onError={() => setImgError(true)}
unoptimized
/>
);
}
return (
<div
className="rounded-full bg-surface-container-high flex items-center justify-center text-on-surface font-bold text-sm"
style={{ width: size, height: size }}
>
{initial}
</div>
);
}
export function Navbar() {
const [open, setOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const router = useRouter();
const { user, loading, logout } = useAuth();
const isHome = pathname === "/";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function sectionHref(anchor: string) {
return isHome ? `#${anchor}` : `/#${anchor}`;
}
const displayName = user?.name || user?.displayName || shortenPubkey(user?.pubkey || "");
const isStaff = user?.role === "ADMIN" || user?.role === "MODERATOR";
function handleLogout() {
setDropdownOpen(false);
setOpen(false);
logout();
router.push("/");
}
return (
<nav className="sticky top-0 z-50 bg-surface/95 backdrop-blur-md">
<div className="flex justify-between items-center max-w-7xl mx-auto px-8 h-20">
<Link
href="/"
className="text-xl font-bold text-primary-container tracking-[-0.02em]"
>
Belgian Bitcoin Embassy
</Link>
<div className="hidden md:flex space-x-10 items-center">
{SECTION_LINKS.map((link) => (
<a
key={link.anchor}
href={sectionHref(link.anchor)}
className="font-medium tracking-tight transition-colors duration-200 text-white/70 hover:text-primary"
>
{link.label}
</a>
))}
{PAGE_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
"font-medium tracking-tight transition-colors duration-200",
pathname.startsWith(link.href)
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
{link.label}
</Link>
))}
<Link
href="/blog"
className={cn(
"font-medium tracking-tight transition-colors duration-200",
pathname.startsWith("/blog")
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
Blog
</Link>
</div>
<div className="hidden md:block">
{loading ? (
<div className="w-24 h-10" />
) : user ? (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors hover:bg-surface-container-high"
>
<ProfileAvatar
picture={user.picture}
name={user.name || user.displayName}
size={32}
/>
<span className="text-sm font-medium text-on-surface max-w-[120px] truncate">
{displayName}
</span>
</button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-52 bg-surface-container-high rounded-xl py-2 shadow-lg shadow-black/30">
<Link
href="/dashboard"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors"
>
<LayoutDashboard size={16} className="text-on-surface-variant" />
Dashboard
</Link>
{isStaff && (
<Link
href="/admin"
onClick={() => setDropdownOpen(false)}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors"
>
<Shield size={16} className="text-on-surface-variant" />
Admin
</Link>
)}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-on-surface hover:bg-surface-bright transition-colors w-full text-left"
>
<LogOut size={16} className="text-on-surface-variant" />
Logout
</button>
</div>
)}
</div>
) : (
<Link href="/login">
<Button variant="primary" size="md">
<span className="flex items-center gap-2">
<LogIn size={16} />
Login
</span>
</Button>
</Link>
)}
</div>
<button
className="md:hidden text-on-surface"
onClick={() => setOpen(!open)}
aria-label="Toggle menu"
>
{open ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{open && (
<div className="md:hidden bg-surface-container px-8 pb-6 space-y-4">
{SECTION_LINKS.map((link) => (
<a
key={link.anchor}
href={sectionHref(link.anchor)}
onClick={() => setOpen(false)}
className="block py-2 font-medium tracking-tight transition-colors text-white/70 hover:text-primary"
>
{link.label}
</a>
))}
{PAGE_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setOpen(false)}
className={cn(
"block py-2 font-medium tracking-tight transition-colors",
pathname.startsWith(link.href)
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
{link.label}
</Link>
))}
<Link
href="/blog"
onClick={() => setOpen(false)}
className={cn(
"block py-2 font-medium tracking-tight transition-colors",
pathname.startsWith("/blog")
? "text-primary font-bold"
: "text-white/70 hover:text-primary"
)}
>
Blog
</Link>
{loading ? null : user ? (
<>
<div className="flex items-center gap-3 pt-4">
<ProfileAvatar
picture={user.picture}
name={user.name || user.displayName}
size={32}
/>
<span className="text-sm font-medium text-on-surface truncate">
{displayName}
</span>
</div>
<Link
href="/dashboard"
onClick={() => setOpen(false)}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors"
>
<LayoutDashboard size={16} />
Dashboard
</Link>
{isStaff && (
<Link
href="/admin"
onClick={() => setOpen(false)}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors"
>
<Shield size={16} />
Admin
</Link>
)}
<button
onClick={handleLogout}
className="flex items-center gap-3 py-2 text-sm font-medium text-white/70 hover:text-primary transition-colors w-full"
>
<LogOut size={16} />
Logout
</button>
</>
) : (
<Link href="/login" onClick={() => setOpen(false)}>
<Button variant="primary" size="md" className="w-full mt-4">
<span className="flex items-center justify-center gap-2">
<LogIn size={16} />
Login
</span>
</Button>
</Link>
)}
</div>
)}
</nav>
);
}

View File

@@ -0,0 +1,41 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/utils";
type ButtonVariant = "primary" | "secondary" | "tertiary" | "telegram";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantStyles: Record<ButtonVariant, string> = {
primary:
"bg-gradient-to-r from-primary to-primary-container text-on-primary font-bold hover:scale-105 active:opacity-80 transition-all",
secondary:
"bg-surface-container-highest text-on-surface hover:bg-surface-bright transition-colors",
tertiary: "text-primary-fixed-dim hover:opacity-80 transition-opacity",
telegram:
"bg-[#24A1DE] text-white hover:opacity-90 transition-opacity",
};
const sizeStyles: Record<ButtonSize, string> = {
sm: "px-4 py-2 text-sm rounded-md",
md: "px-6 py-2.5 rounded-lg",
lg: "px-10 py-4 rounded-lg font-bold",
};
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = "primary", size = "md", className, children, ...rest }, ref) => (
<button
ref={ref}
className={cn(variantStyles[variant], sizeStyles[size], className)}
{...rest}
>
{children}
</button>
)
);
Button.displayName = "Button";
export { Button, type ButtonProps };

View File

@@ -0,0 +1,27 @@
import { type HTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/utils";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
hover?: boolean;
variant?: "low" | "default";
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ hover, variant = "low", className, children, ...rest }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl p-6",
variant === "low" ? "bg-surface-container-low" : "bg-surface-container",
hover && "hover:bg-surface-container-high transition-colors",
className
)}
{...rest}
>
{children}
</div>
)
);
Card.displayName = "Card";
export { Card, type CardProps };

151
frontend/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,151 @@
"use client";
import { useState, useEffect, useCallback, createContext, useContext } from "react";
import { api } from "@/lib/api";
import {
hasNostrExtension,
getPublicKey,
signEvent,
createAuthEvent,
fetchNostrProfile,
createBunkerSigner,
type BunkerSignerInterface,
type NostrProfile,
} from "@/lib/nostr";
export interface User {
pubkey: string;
role: string;
username?: string;
name?: string;
picture?: string;
about?: string;
nip05?: string;
displayName?: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: () => Promise<User>;
loginWithBunker: (input: string) => Promise<User>;
loginWithConnectedSigner: (signer: BunkerSignerInterface) => Promise<User>;
logout: () => void;
isAdmin: boolean;
isModerator: boolean;
}
export const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
login: async () => ({ pubkey: "", role: "USER" }),
loginWithBunker: async () => ({ pubkey: "", role: "USER" }),
loginWithConnectedSigner: async () => ({ pubkey: "", role: "USER" }),
logout: () => {},
isAdmin: false,
isModerator: false,
});
export function useAuth() {
return useContext(AuthContext);
}
export function useAuthProvider(): AuthContextType {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const stored = localStorage.getItem("bbe_user");
const token = localStorage.getItem("bbe_token");
if (stored && token) {
try {
setUser(JSON.parse(stored));
} catch {
localStorage.removeItem("bbe_user");
localStorage.removeItem("bbe_token");
}
}
setLoading(false);
}, []);
const completeAuth = useCallback(
async (
getPubKey: () => Promise<string>,
sign: (event: any) => Promise<any>
): Promise<User> => {
const pubkey = await getPubKey();
const { challenge } = await api.getChallenge(pubkey);
const event = createAuthEvent(pubkey, challenge);
const signedEvent = await sign(event);
const { token, user: userData } = await api.verify(pubkey, signedEvent);
let profile: NostrProfile = {};
try {
profile = await fetchNostrProfile(pubkey);
} catch {
// Profile fetch is best-effort
}
const fullUser: User = {
...userData,
name: profile.name,
displayName: profile.displayName,
picture: profile.picture,
about: profile.about,
nip05: profile.nip05,
username: userData.username,
};
localStorage.setItem("bbe_token", token);
localStorage.setItem("bbe_user", JSON.stringify(fullUser));
setUser(fullUser);
return fullUser;
},
[]
);
const login = useCallback(async (): Promise<User> => {
if (!hasNostrExtension()) {
throw new Error("Please install a Nostr extension (e.g., Alby, nos2x)");
}
return completeAuth(getPublicKey, signEvent);
}, [completeAuth]);
const loginWithConnectedSigner = useCallback(
async (signer: BunkerSignerInterface): Promise<User> => {
return completeAuth(
() => signer.getPublicKey(),
(event) => signer.signEvent(event)
);
},
[completeAuth]
);
const loginWithBunker = useCallback(
async (input: string): Promise<User> => {
const { signer } = await createBunkerSigner(input);
try {
return await loginWithConnectedSigner(signer);
} finally {
await signer.close().catch(() => {});
}
},
[loginWithConnectedSigner]
);
const logout = useCallback(() => {
localStorage.removeItem("bbe_token");
localStorage.removeItem("bbe_user");
setUser(null);
}, []);
return {
user,
loading,
login,
loginWithBunker,
loginWithConnectedSigner,
logout,
isAdmin: user?.role === "ADMIN",
isModerator: user?.role === "MODERATOR" || user?.role === "ADMIN",
};
}

181
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,181 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:4000/api";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
const headers: HeadersInit = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
};
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "Request failed" }));
throw new Error(error.message || `HTTP ${res.status}`);
}
return res.json();
}
export const api = {
// Auth
getChallenge: (pubkey: string) =>
request<{ challenge: string }>("/auth/challenge", {
method: "POST",
body: JSON.stringify({ pubkey }),
}),
verify: (pubkey: string, signedEvent: any) =>
request<{ token: string; user: { pubkey: string; role: string; username?: string } }>("/auth/verify", {
method: "POST",
body: JSON.stringify({ pubkey, signedEvent }),
}),
// Posts
getPosts: (params?: { category?: string; page?: number; limit?: number; all?: boolean }) => {
const searchParams = new URLSearchParams();
if (params?.category) searchParams.set("category", params.category);
if (params?.page) searchParams.set("page", String(params.page));
if (params?.limit) searchParams.set("limit", String(params.limit));
if (params?.all) searchParams.set("all", "true");
return request<{ posts: any[]; total: number }>(`/posts?${searchParams}`);
},
getPost: (slug: string) => request<any>(`/posts/${slug}`),
getPostReactions: (slug: string) =>
request<{ count: number; reactions: any[] }>(`/posts/${slug}/reactions`),
getPostReplies: (slug: string) =>
request<{ count: number; replies: any[] }>(`/posts/${slug}/replies`),
importPost: (data: { eventId?: string; naddr?: string }) =>
request<any>("/posts/import", { method: "POST", body: JSON.stringify(data) }),
updatePost: (id: string, data: any) =>
request<any>(`/posts/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deletePost: (id: string) =>
request<void>(`/posts/${id}`, { method: "DELETE" }),
// Meetups
getMeetups: (params?: { status?: string; admin?: boolean }) => {
const searchParams = new URLSearchParams();
if (params?.status) searchParams.set("status", params.status);
if (params?.admin) searchParams.set("admin", "true");
const qs = searchParams.toString();
return request<any[]>(`/meetups${qs ? `?${qs}` : ""}`);
},
getMeetup: (id: string) => request<any>(`/meetups/${id}`),
createMeetup: (data: any) =>
request<any>("/meetups", { method: "POST", body: JSON.stringify(data) }),
updateMeetup: (id: string, data: any) =>
request<any>(`/meetups/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteMeetup: (id: string) =>
request<void>(`/meetups/${id}`, { method: "DELETE" }),
duplicateMeetup: (id: string) =>
request<any>(`/meetups/${id}/duplicate`, { method: "POST" }),
bulkMeetupAction: (action: string, ids: string[]) =>
request<any>("/meetups/bulk", { method: "POST", body: JSON.stringify({ action, ids }) }),
// Moderation
getHiddenContent: () => request<any[]>("/moderation/hidden"),
hideContent: (nostrEventId: string, reason?: string) =>
request<any>("/moderation/hide", { method: "POST", body: JSON.stringify({ nostrEventId, reason }) }),
unhideContent: (id: string) =>
request<void>(`/moderation/unhide/${id}`, { method: "DELETE" }),
getBlockedPubkeys: () => request<any[]>("/moderation/blocked"),
blockPubkey: (pubkey: string, reason?: string) =>
request<any>("/moderation/block", { method: "POST", body: JSON.stringify({ pubkey, reason }) }),
unblockPubkey: (id: string) =>
request<void>(`/moderation/unblock/${id}`, { method: "DELETE" }),
// Users
getUsers: () => request<any[]>("/users"),
promoteUser: (pubkey: string) =>
request<any>("/users/promote", { method: "POST", body: JSON.stringify({ pubkey }) }),
demoteUser: (pubkey: string) =>
request<any>("/users/demote", { method: "POST", body: JSON.stringify({ pubkey }) }),
// Categories
getCategories: () => request<any[]>("/categories"),
createCategory: (data: { name: string; slug: string }) =>
request<any>("/categories", { method: "POST", body: JSON.stringify(data) }),
updateCategory: (id: string, data: any) =>
request<any>(`/categories/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteCategory: (id: string) =>
request<void>(`/categories/${id}`, { method: "DELETE" }),
// Relays
getRelays: () => request<any[]>("/relays"),
addRelay: (data: { url: string; priority?: number }) =>
request<any>("/relays", { method: "POST", body: JSON.stringify(data) }),
updateRelay: (id: string, data: any) =>
request<any>(`/relays/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteRelay: (id: string) =>
request<void>(`/relays/${id}`, { method: "DELETE" }),
testRelay: (id: string) =>
request<{ success: boolean }>(`/relays/${id}/test`, { method: "POST" }),
// Settings
getSettings: () => request<Record<string, string>>("/settings"),
getPublicSettings: () => request<Record<string, string>>("/settings/public"),
updateSetting: (key: string, value: string) =>
request<any>("/settings", { method: "PATCH", body: JSON.stringify({ key, value }) }),
// Nostr tools
fetchNostrEvent: (data: { eventId?: string; naddr?: string }) =>
request<any>("/nostr/fetch", { method: "POST", body: JSON.stringify(data) }),
refreshCache: () =>
request<any>("/nostr/cache/refresh", { method: "POST" }),
debugEvent: (eventId: string) =>
request<any>(`/nostr/debug/${eventId}`),
// Media
uploadMedia: async (file: File) => {
const token = typeof window !== "undefined" ? localStorage.getItem("bbe_token") : null;
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`${API_URL}/media/upload`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "Upload failed" }));
throw new Error(error.error || error.message || `HTTP ${res.status}`);
}
return res.json() as Promise<{ id: string; slug: string; url: string }>;
},
getMediaList: () => request<any[]>("/media"),
getMedia: (id: string) => request<any>(`/media/${id}`),
deleteMedia: (id: string) =>
request<void>(`/media/${id}`, { method: "DELETE" }),
updateMedia: (id: string, data: { title?: string; description?: string; altText?: string }) =>
request<any>(`/media/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
// FAQs
getFaqs: () => request<any[]>('/faqs'),
getFaqsAll: () => request<any[]>('/faqs?all=true'),
getAllFaqs: () => request<any[]>('/faqs/all'),
createFaq: (data: { question: string; answer: string; showOnHomepage?: boolean }) =>
request<any>('/faqs', { method: 'POST', body: JSON.stringify(data) }),
updateFaq: (id: string, data: { question?: string; answer?: string; showOnHomepage?: boolean }) =>
request<any>(`/faqs/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteFaq: (id: string) =>
request<void>(`/faqs/${id}`, { method: 'DELETE' }),
reorderFaqs: (items: { id: string; order: number }[]) =>
request<any>('/faqs/reorder', { method: 'POST', body: JSON.stringify({ items }) }),
// Profile (self)
updateProfile: (data: { username?: string }) =>
request<any>('/users/me', { method: 'PATCH', body: JSON.stringify(data) }),
checkUsername: (username: string) =>
request<{ available: boolean; reason?: string }>(
`/users/me/username-check?username=${encodeURIComponent(username)}`
),
// Submissions
createSubmission: (data: { eventId?: string; naddr?: string; title: string }) =>
request<any>("/submissions", { method: "POST", body: JSON.stringify(data) }),
getMySubmissions: () =>
request<any[]>("/submissions/mine"),
getSubmissions: (status?: string) => {
const params = status ? `?status=${status}` : "";
return request<any[]>(`/submissions${params}`);
},
reviewSubmission: (id: string, data: { status: string; reviewNote?: string }) =>
request<any>(`/submissions/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
};

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