first commit
Made-with: Cursor
This commit is contained in:
24
.env.example
Normal file
24
.env.example
Normal 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
45
.gitignore
vendored
Normal 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
106
README.md
Normal 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
|
||||
99
backend/config/blocked-usernames.txt
Normal file
99
backend/config/blocked-usernames.txt
Normal 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
2441
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
backend/package.json
Normal file
41
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
149
backend/prisma/schema.prisma
Normal file
149
backend/prisma/schema.prisma
Normal 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
85
backend/prisma/seed.ts
Normal 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
53
backend/src/api/auth.ts
Normal 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
163
backend/src/api/calendar.ts
Normal 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;
|
||||
103
backend/src/api/categories.ts
Normal file
103
backend/src/api/categories.ts
Normal 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
155
backend/src/api/faqs.ts
Normal 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
217
backend/src/api/media.ts
Normal 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
253
backend/src/api/meetups.ts
Normal 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;
|
||||
143
backend/src/api/moderation.ts
Normal file
143
backend/src/api/moderation.ts
Normal 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
32
backend/src/api/nip05.ts
Normal 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
88
backend/src/api/nostr.ts
Normal 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
243
backend/src/api/posts.ts
Normal 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
141
backend/src/api/relays.ts
Normal 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;
|
||||
79
backend/src/api/settings.ts
Normal file
79
backend/src/api/settings.ts
Normal 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;
|
||||
114
backend/src/api/submissions.ts
Normal file
114
backend/src/api/submissions.ts
Normal 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
177
backend/src/api/users.ts
Normal 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
9
backend/src/db/prisma.ts
Normal 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
71
backend/src/index.ts
Normal 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);
|
||||
48
backend/src/middleware/auth.ts
Normal file
48
backend/src/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
90
backend/src/services/auth.ts
Normal file
90
backend/src/services/auth.ts
Normal 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';
|
||||
},
|
||||
};
|
||||
146
backend/src/services/nostr.ts
Normal file
146
backend/src/services/nostr.ts
Normal 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
19
backend/tsconfig.json
Normal 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
78
context/design.md
Normal 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. Do’s and Don’ts
|
||||
|
||||
### 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.
|
||||
|
||||
### Don’t:
|
||||
- **Don’t use "Crypto-Green/Red":** Use `error` (`#ffb4ab`) sparingly for critical warnings only. Educational growth is signaled by Bitcoin Orange, not "Stock Market Green."
|
||||
- **Don’t use Dividers:** If you need a line to separate content, you have failed to use the Spacing Scale correctly.
|
||||
- **Don’t Over-Animate:** Transitions should be "Snappy & Subtle" (200ms ease-out). Avoid bouncing or heavy staggered entrances.
|
||||
296
context/homepage.html
Normal file
296
context/homepage.html
Normal 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&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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
283
context/join community.html
Normal 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
398
context/overview.md
Normal 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
773
context/pages.md
Normal 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.
|
||||
|
||||
19
frontend/app/.well-known/nostr.json/route.ts
Normal file
19
frontend/app/.well-known/nostr.json/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
337
frontend/app/admin/blog/page.tsx
Normal file
337
frontend/app/admin/blog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
frontend/app/admin/categories/page.tsx
Normal file
184
frontend/app/admin/categories/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
844
frontend/app/admin/events/page.tsx
Normal file
844
frontend/app/admin/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
354
frontend/app/admin/faq/page.tsx
Normal file
354
frontend/app/admin/faq/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
325
frontend/app/admin/gallery/page.tsx
Normal file
325
frontend/app/admin/gallery/page.tsx
Normal 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} · {formatFileSize(editingItem.size)} · {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>
|
||||
);
|
||||
}
|
||||
33
frontend/app/admin/layout.tsx
Normal file
33
frontend/app/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
frontend/app/admin/moderation/page.tsx
Normal file
256
frontend/app/admin/moderation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
frontend/app/admin/nostr/page.tsx
Normal file
147
frontend/app/admin/nostr/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
frontend/app/admin/overview/page.tsx
Normal file
173
frontend/app/admin/overview/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/app/admin/page.tsx
Normal file
74
frontend/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
frontend/app/admin/relays/page.tsx
Normal file
225
frontend/app/admin/relays/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
frontend/app/admin/settings/page.tsx
Normal file
105
frontend/app/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
frontend/app/admin/submissions/page.tsx
Normal file
226
frontend/app/admin/submissions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
frontend/app/admin/users/page.tsx
Normal file
162
frontend/app/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
400
frontend/app/blog/[slug]/BlogPostClient.tsx
Normal file
400
frontend/app/blog/[slug]/BlogPostClient.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
frontend/app/blog/[slug]/page.tsx
Normal file
84
frontend/app/blog/[slug]/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
frontend/app/blog/layout.tsx
Normal file
17
frontend/app/blog/layout.tsx
Normal 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
283
frontend/app/blog/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
frontend/app/calendar/route.ts
Normal file
30
frontend/app/calendar/route.ts
Normal 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"',
|
||||
},
|
||||
});
|
||||
}
|
||||
17
frontend/app/community/layout.tsx
Normal file
17
frontend/app/community/layout.tsx
Normal 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;
|
||||
}
|
||||
33
frontend/app/community/page.tsx
Normal file
33
frontend/app/community/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
frontend/app/contact/page.tsx
Normal file
86
frontend/app/contact/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
frontend/app/dashboard/layout.tsx
Normal file
47
frontend/app/dashboard/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
521
frontend/app/dashboard/page.tsx
Normal file
521
frontend/app/dashboard/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
162
frontend/app/events/[id]/EventDetailClient.tsx
Normal file
162
frontend/app/events/[id]/EventDetailClient.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
frontend/app/events/[id]/page.tsx
Normal file
86
frontend/app/events/[id]/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
frontend/app/events/layout.tsx
Normal file
17
frontend/app/events/layout.tsx
Normal 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;
|
||||
}
|
||||
190
frontend/app/events/page.tsx
Normal file
190
frontend/app/events/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
frontend/app/faq/layout.tsx
Normal file
17
frontend/app/faq/layout.tsx
Normal 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
103
frontend/app/faq/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
frontend/app/gallery/[slug]/page.tsx
Normal file
134
frontend/app/gallery/[slug]/page.tsx
Normal 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
34
frontend/app/globals.css
Normal 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
92
frontend/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/app/login/layout.tsx
Normal file
11
frontend/app/login/layout.tsx
Normal 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
292
frontend/app/login/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
178
frontend/app/media/[id]/route.ts
Normal file
178
frontend/app/media/[id]/route.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
29
frontend/app/not-found.tsx
Normal file
29
frontend/app/not-found.tsx
Normal 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're looking for doesn'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
152
frontend/app/og/route.tsx
Normal 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
72
frontend/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
frontend/app/privacy/page.tsx
Normal file
78
frontend/app/privacy/page.tsx
Normal 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
17
frontend/app/robots.ts
Normal 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
56
frontend/app/sitemap.ts
Normal 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];
|
||||
}
|
||||
77
frontend/app/terms/page.tsx
Normal file
77
frontend/app/terms/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
frontend/components/admin/AdminSidebar.tsx
Normal file
121
frontend/components/admin/AdminSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
frontend/components/admin/MediaPickerModal.tsx
Normal file
155
frontend/components/admin/MediaPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
frontend/components/providers/AuthProvider.tsx
Normal file
8
frontend/components/providers/AuthProvider.tsx
Normal 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>;
|
||||
}
|
||||
8
frontend/components/providers/ClientProviders.tsx
Normal file
8
frontend/components/providers/ClientProviders.tsx
Normal 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>;
|
||||
}
|
||||
23
frontend/components/public/AboutSection.tsx
Normal file
23
frontend/components/public/AboutSection.tsx
Normal 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">
|
||||
“Fix the money, fix the world.”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
81
frontend/components/public/BlogPreviewSection.tsx
Normal file
81
frontend/components/public/BlogPreviewSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
frontend/components/public/CommunityLinksSection.tsx
Normal file
169
frontend/components/public/CommunityLinksSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/components/public/FAQSection.tsx
Normal file
77
frontend/components/public/FAQSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend/components/public/FinalCTASection.tsx
Normal file
44
frontend/components/public/FinalCTASection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/components/public/Footer.tsx
Normal file
37
frontend/components/public/Footer.tsx
Normal 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">
|
||||
© Belgian Bitcoin Embassy. No counterparty risk.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
71
frontend/components/public/HeroSection.tsx
Normal file
71
frontend/components/public/HeroSection.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
199
frontend/components/public/JsonLd.tsx
Normal file
199
frontend/components/public/JsonLd.tsx
Normal 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}`,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
frontend/components/public/KnowledgeCards.tsx
Normal file
49
frontend/components/public/KnowledgeCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
frontend/components/public/MeetupsSection.tsx
Normal file
137
frontend/components/public/MeetupsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
300
frontend/components/public/Navbar.tsx
Normal file
300
frontend/components/public/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/components/ui/Button.tsx
Normal file
41
frontend/components/ui/Button.tsx
Normal 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 };
|
||||
27
frontend/components/ui/Card.tsx
Normal file
27
frontend/components/ui/Card.tsx
Normal 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
151
frontend/hooks/useAuth.ts
Normal 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
181
frontend/lib/api.ts
Normal 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
Reference in New Issue
Block a user