dev #1
Generated
+34
-1
@@ -20,7 +20,8 @@
|
||||
"nostr-tools": "^2.10.0",
|
||||
"slugify": "^1.6.8",
|
||||
"ulid": "^3.0.2",
|
||||
"uuid": "^11.0.0"
|
||||
"uuid": "^11.0.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -30,6 +31,7 @@
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"prisma": "^6.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
@@ -807,6 +809,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -2553,6 +2565,27 @@
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"db:baseline-and-migrate": "bash scripts/baseline-and-migrate.sh",
|
||||
"db:seed": "dotenv -e ../.env -e .env -- prisma db seed",
|
||||
"db:studio": "dotenv -e ../.env -e .env -- prisma studio",
|
||||
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy"
|
||||
"migrate:deploy": "dotenv -e ../.env -e .env -- prisma migrate deploy",
|
||||
"backfill-submissions": "dotenv -e ../.env -e .env -- tsx scripts/backfill-approved-submissions.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
@@ -28,7 +29,8 @@
|
||||
"nostr-tools": "^2.10.0",
|
||||
"slugify": "^1.6.8",
|
||||
"ulid": "^3.0.2",
|
||||
"uuid": "^11.0.0"
|
||||
"uuid": "^11.0.0",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
@@ -38,6 +40,7 @@
|
||||
"@types/morgan": "^1.9.10",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"prisma": "^6.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// One-time backfill: publish any APPROVED submissions that never got a blog
|
||||
// Post (they predate auto-publish-on-approval). Idempotent — already-imported
|
||||
// submissions are skipped, so it is safe to re-run.
|
||||
//
|
||||
// Usage (from backend/):
|
||||
// npm run backfill-submissions
|
||||
import { prisma } from '../src/db/prisma';
|
||||
import { importPostFromNostr, resolveSubmissionImport } from '../src/services/postImport';
|
||||
|
||||
async function main() {
|
||||
const approved = await prisma.submission.findMany({
|
||||
where: { status: 'APPROVED' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Found ${approved.length} APPROVED submission(s).`);
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const submission of approved) {
|
||||
const label = `"${submission.title}" (${submission.id})`;
|
||||
try {
|
||||
const importInput = await resolveSubmissionImport(submission);
|
||||
if (!importInput) {
|
||||
console.warn(` FAILED ${label}: could not resolve Nostr event from relays.`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.post.findUnique({
|
||||
where: { nostrEventId: importInput.nostrEventId },
|
||||
});
|
||||
if (existing) {
|
||||
console.log(` SKIP ${label}: already published (slug: ${existing.slug}).`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const post = await importPostFromNostr(importInput);
|
||||
console.log(` IMPORT ${label}: published (slug: ${post?.slug}).`);
|
||||
imported++;
|
||||
} catch (err) {
|
||||
console.error(` FAILED ${label}:`, err);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Imported: ${imported}, Skipped: ${skipped}, Failed: ${failed}.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
// The Nostr relay pool keeps websockets open, which would otherwise keep the
|
||||
// process alive after the work is done.
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(async (err) => {
|
||||
console.error('Backfill error:', err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
+116
-67
@@ -1,10 +1,89 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../db/prisma';
|
||||
import { requireAuth, requires } from '../middleware/auth';
|
||||
import { authService, type ResolvedAccess } from '../services/auth';
|
||||
import { looksLikeApiKey, resolveApiKey } from '../services/apiKeys';
|
||||
import { nostrService } from '../services/nostr';
|
||||
import { importPostFromNostr } from '../services/postImport';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Resolves access for an optional Bearer token (JWT or API key) without
|
||||
// rejecting unauthenticated callers, so listing endpoints can stay public while
|
||||
// still recognizing staff who pass `?all=true`.
|
||||
async function resolveOptionalAccess(req: Request): Promise<ResolvedAccess | null> {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) return null;
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
if (looksLikeApiKey(token)) {
|
||||
return await resolveApiKey(token);
|
||||
}
|
||||
const payload = jwt.verify(token, JWT_SECRET) as { pubkey: string };
|
||||
return await authService.resolveAccess(payload.pubkey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true when the requester is authenticated and may see hidden posts.
|
||||
async function canViewHiddenPosts(req: Request): Promise<boolean> {
|
||||
const access = await resolveOptionalAccess(req);
|
||||
return !!access && (access.isSuperAdmin || access.permissions.has('blog.draft'));
|
||||
}
|
||||
|
||||
// Resolves a blog slug to the Nostr event id used for reactions/replies,
|
||||
// whether it's an indexed post or a live NIP-19 reference. Returns null if
|
||||
// neither resolves.
|
||||
async function resolveEventIdForSlug(slug: string): Promise<string | null> {
|
||||
const post = await prisma.post.findUnique({ where: { slug } });
|
||||
if (post) return post.nostrEventId;
|
||||
const event = await nostrService.resolveEventByIdentifier(slug);
|
||||
return event?.id ?? null;
|
||||
}
|
||||
|
||||
function tagValue(event: { tags?: string[][] }, name: string): string | undefined {
|
||||
return event.tags?.find((t) => t[0] === name)?.[1];
|
||||
}
|
||||
|
||||
// Shapes a live Nostr longform event into the same JSON the frontend expects
|
||||
// from an indexed Post, so naddr/nevent/note links render through the regular
|
||||
// post template instead of 404ing. `identifier` is the original URL segment and
|
||||
// becomes the slug so canonical URLs and reaction/reply lookups stay stable.
|
||||
function buildPostShapeFromEvent(
|
||||
event: { id: string; pubkey: string; content: string; created_at: number; tags?: string[][] },
|
||||
identifier: string
|
||||
) {
|
||||
const title = tagValue(event, 'title') || 'Untitled';
|
||||
const summary = tagValue(event, 'summary') || null;
|
||||
const image = tagValue(event, 'image') || null;
|
||||
const publishedAtSec = Number(tagValue(event, 'published_at')) || event.created_at;
|
||||
const categories = (event.tags || [])
|
||||
.filter((t) => t[0] === 't' && t[1])
|
||||
.map((t) => ({ category: { id: t[1], name: t[1], slug: t[1] } }));
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
nostrEventId: event.id,
|
||||
naddr: identifier.startsWith('naddr') ? identifier : null,
|
||||
title,
|
||||
slug: identifier,
|
||||
content: event.content || '',
|
||||
excerpt: summary,
|
||||
image,
|
||||
authorPubkey: event.pubkey,
|
||||
authorName: null,
|
||||
featured: false,
|
||||
visible: true,
|
||||
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||
createdAt: new Date(event.created_at * 1000).toISOString(),
|
||||
categories,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
@@ -12,7 +91,10 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
const category = req.query.category as string | undefined;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = { visible: true };
|
||||
const includeHidden = req.query.all === 'true' && (await canViewHiddenPosts(req));
|
||||
|
||||
const where: any = {};
|
||||
if (!includeHidden) where.visible = true;
|
||||
if (category) {
|
||||
where.categories = {
|
||||
some: { category: { slug: category } },
|
||||
@@ -34,6 +116,7 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
|
||||
res.json({
|
||||
posts,
|
||||
total,
|
||||
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -44,19 +127,31 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
|
||||
router.get('/:slug', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { slug: req.params.slug as string },
|
||||
where: { slug },
|
||||
include: {
|
||||
categories: { include: { category: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
res.status(404).json({ error: 'Post not found' });
|
||||
if (post) {
|
||||
// Indexed posts store an empty body (the canonical copy lives on Nostr);
|
||||
// the frontend hydrates the body live from relays. Return as-is.
|
||||
res.json(post);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(post);
|
||||
// Not indexed: resolve the slug as a live NIP-19 reference so the page has
|
||||
// real metadata (title, summary, image) for SEO instead of "Post Not
|
||||
// Found". The frontend still fetches the body from relays for display.
|
||||
const event = await nostrService.resolveEventByIdentifier(slug);
|
||||
if (event) {
|
||||
res.json(buildPostShapeFromEvent(event, slug));
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(404).json({ error: 'Post not found' });
|
||||
} catch (err) {
|
||||
console.error('Get post error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -76,62 +171,14 @@ router.post(
|
||||
return;
|
||||
}
|
||||
|
||||
const slugBase = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
|
||||
|
||||
const post = await prisma.post.upsert({
|
||||
where: { nostrEventId },
|
||||
update: {
|
||||
title,
|
||||
excerpt: excerpt || undefined,
|
||||
naddr: naddr || undefined,
|
||||
},
|
||||
create: {
|
||||
nostrEventId,
|
||||
naddr: naddr || null,
|
||||
title,
|
||||
slug,
|
||||
excerpt: excerpt || null,
|
||||
authorPubkey,
|
||||
publishedAt: publishedAt
|
||||
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
|
||||
: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(tags) && tags.length > 0) {
|
||||
const categoryIds: string[] = [];
|
||||
for (const tag of tags as string[]) {
|
||||
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
if (!catSlug) continue;
|
||||
const category = await prisma.category.upsert({
|
||||
where: { slug: catSlug },
|
||||
update: {},
|
||||
create: {
|
||||
name: tag.charAt(0).toUpperCase() + tag.slice(1),
|
||||
slug: catSlug,
|
||||
},
|
||||
});
|
||||
categoryIds.push(category.id);
|
||||
}
|
||||
|
||||
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
|
||||
if (categoryIds.length > 0) {
|
||||
await prisma.postCategory.createMany({
|
||||
data: categoryIds.map((categoryId) => ({
|
||||
postId: post.id,
|
||||
categoryId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.post.findUnique({
|
||||
where: { id: post.id },
|
||||
include: { categories: { include: { category: true } } },
|
||||
const result = await importPostFromNostr({
|
||||
nostrEventId,
|
||||
naddr,
|
||||
title,
|
||||
excerpt,
|
||||
authorPubkey,
|
||||
publishedAt,
|
||||
tags,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
@@ -195,13 +242,14 @@ router.patch(
|
||||
|
||||
router.get('/:slug/reactions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
|
||||
if (!post) {
|
||||
const slug = req.params.slug as string;
|
||||
const eventId = await resolveEventIdForSlug(slug);
|
||||
if (!eventId) {
|
||||
res.status(404).json({ error: 'Post not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const reactions = await nostrService.fetchReactions(post.nostrEventId);
|
||||
const reactions = await nostrService.fetchReactions(eventId);
|
||||
res.json({ count: reactions.length, reactions });
|
||||
} catch (err) {
|
||||
console.error('Get reactions error:', err);
|
||||
@@ -211,14 +259,15 @@ router.get('/:slug/reactions', async (req: Request, res: Response) => {
|
||||
|
||||
router.get('/:slug/replies', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const post = await prisma.post.findUnique({ where: { slug: req.params.slug as string } });
|
||||
if (!post) {
|
||||
const slug = req.params.slug as string;
|
||||
const eventId = await resolveEventIdForSlug(slug);
|
||||
if (!eventId) {
|
||||
res.status(404).json({ error: 'Post not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const [replies, hiddenContent, blockedPubkeys] = await Promise.all([
|
||||
nostrService.fetchReplies(post.nostrEventId),
|
||||
nostrService.fetchReplies(eventId),
|
||||
prisma.hiddenContent.findMany({ select: { nostrEventId: true } }),
|
||||
prisma.blockedPubkey.findMany({ select: { pubkey: true } }),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../db/prisma';
|
||||
import { requireAuth, requires } from '../middleware/auth';
|
||||
import { importPostFromNostr, resolveSubmissionImport } from '../services/postImport';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -94,6 +95,22 @@ router.patch(
|
||||
return;
|
||||
}
|
||||
|
||||
// Approval publishes the referenced Nostr article to the blog. Do this
|
||||
// before flipping the status so a failed import doesn't leave a submission
|
||||
// marked APPROVED without a corresponding live post.
|
||||
let post = null;
|
||||
if (status === 'APPROVED') {
|
||||
const importInput = await resolveSubmissionImport(submission);
|
||||
if (!importInput) {
|
||||
res.status(422).json({
|
||||
error:
|
||||
'Could not resolve the submitted Nostr event from relays. The post was not published, so the submission was left unchanged.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
post = await importPostFromNostr(importInput);
|
||||
}
|
||||
|
||||
const updated = await prisma.submission.update({
|
||||
where: { id: req.params.id as string },
|
||||
data: {
|
||||
@@ -103,7 +120,7 @@ router.patch(
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
res.json({ submission: updated, post });
|
||||
} catch (err) {
|
||||
console.error('Review submission error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import WebSocket from 'ws';
|
||||
import { SimplePool, nip19 } from 'nostr-tools';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
// nostr-tools' SimplePool relies on a global WebSocket. Node only exposes one
|
||||
// from v22 onward, so on older runtimes (this backend runs Node 20) every relay
|
||||
// query silently fails without this polyfill. Set before any pool connects.
|
||||
if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === 'undefined') {
|
||||
(globalThis as { WebSocket?: unknown }).WebSocket = WebSocket;
|
||||
}
|
||||
|
||||
const pool = new SimplePool();
|
||||
|
||||
async function getRelayUrls(): Promise<string[]> {
|
||||
@@ -110,6 +118,32 @@ export const nostrService = {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache-first: addressable events are keyed by kind+pubkey+d-tag, not by a
|
||||
// stable event id, so look them up among cached events of the same author.
|
||||
// Avoids re-querying relays on every repeat visit to the same naddr.
|
||||
const cached = await prisma.nostrEventCache.findMany({
|
||||
where: { kind: decoded.kind, pubkey: decoded.pubkey },
|
||||
});
|
||||
for (const c of cached) {
|
||||
let tags: string[][] = [];
|
||||
try {
|
||||
tags = JSON.parse(c.tags);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const dTag = tags.find((t) => t[0] === 'd');
|
||||
if (dTag?.[1] === decoded.identifier) {
|
||||
return {
|
||||
id: c.eventId,
|
||||
kind: c.kind,
|
||||
pubkey: c.pubkey,
|
||||
content: c.content,
|
||||
tags,
|
||||
created_at: c.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const siteRelays = await getRelayUrls();
|
||||
const naddrRelays = decoded.relays || [];
|
||||
const filter = {
|
||||
@@ -150,6 +184,35 @@ export const nostrService = {
|
||||
return null;
|
||||
},
|
||||
|
||||
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
|
||||
// event id to the underlying Nostr event, reusing the cache-aware fetchers.
|
||||
// Returns null if the string can't be decoded or the event isn't found.
|
||||
async resolveEventByIdentifier(identifier: string) {
|
||||
const trimmed = identifier.trim();
|
||||
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return nostrService.fetchEvent(trimmed.toLowerCase());
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = nip19.decode(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'naddr':
|
||||
return nostrService.fetchLongformEvent(trimmed);
|
||||
case 'nevent':
|
||||
return nostrService.fetchEvent((decoded.data as nip19.EventPointer).id);
|
||||
case 'note':
|
||||
return nostrService.fetchEvent(decoded.data as string);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchReactions(eventId: string) {
|
||||
const relays = await getRelayUrls();
|
||||
if (relays.length === 0) return [];
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { prisma } from '../db/prisma';
|
||||
import { nostrService } from './nostr';
|
||||
|
||||
export interface ImportPostInput {
|
||||
nostrEventId: string;
|
||||
naddr?: string | null;
|
||||
title: string;
|
||||
excerpt?: string | null;
|
||||
authorPubkey: string;
|
||||
publishedAt?: number | string | null;
|
||||
tags?: string[];
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
// Upserts a blog Post from a Nostr longform event. Shared by the manual import
|
||||
// endpoint and the submission-approval flow so both produce identical results
|
||||
// (auto slug, category links from `t` tags, visible by default).
|
||||
export async function importPostFromNostr(input: ImportPostInput) {
|
||||
const { nostrEventId, naddr, title, excerpt, authorPubkey, publishedAt, tags, visible } = input;
|
||||
|
||||
const slugBase = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const slug = `${slugBase}-${nostrEventId.slice(0, 8)}`;
|
||||
|
||||
const post = await prisma.post.upsert({
|
||||
where: { nostrEventId },
|
||||
update: {
|
||||
title,
|
||||
excerpt: excerpt || undefined,
|
||||
naddr: naddr || undefined,
|
||||
},
|
||||
create: {
|
||||
nostrEventId,
|
||||
naddr: naddr || null,
|
||||
title,
|
||||
slug,
|
||||
excerpt: excerpt || null,
|
||||
authorPubkey,
|
||||
visible: visible ?? true,
|
||||
publishedAt: publishedAt
|
||||
? new Date(typeof publishedAt === 'number' ? publishedAt * 1000 : publishedAt)
|
||||
: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(tags) && tags.length > 0) {
|
||||
const categoryIds: string[] = [];
|
||||
for (const tag of tags) {
|
||||
const catSlug = tag.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
if (!catSlug) continue;
|
||||
const category = await prisma.category.upsert({
|
||||
where: { slug: catSlug },
|
||||
update: {},
|
||||
create: {
|
||||
name: tag.charAt(0).toUpperCase() + tag.slice(1),
|
||||
slug: catSlug,
|
||||
},
|
||||
});
|
||||
categoryIds.push(category.id);
|
||||
}
|
||||
|
||||
await prisma.postCategory.deleteMany({ where: { postId: post.id } });
|
||||
if (categoryIds.length > 0) {
|
||||
await prisma.postCategory.createMany({
|
||||
data: categoryIds.map((categoryId) => ({
|
||||
postId: post.id,
|
||||
categoryId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return prisma.post.findUnique({
|
||||
where: { id: post.id },
|
||||
include: { categories: { include: { category: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export interface SubmissionRef {
|
||||
eventId: string | null;
|
||||
naddr: string | null;
|
||||
title: string;
|
||||
authorPubkey: string;
|
||||
}
|
||||
|
||||
// Resolves the Nostr longform event referenced by a submission into a blog
|
||||
// import payload. Returns null when the event cannot be resolved into something
|
||||
// publishable (e.g. naddr that no relay can serve and no fallback event id).
|
||||
export async function resolveSubmissionImport(
|
||||
submission: SubmissionRef
|
||||
): Promise<ImportPostInput | null> {
|
||||
const event = submission.naddr
|
||||
? await nostrService.fetchLongformEvent(submission.naddr)
|
||||
: submission.eventId
|
||||
? await nostrService.fetchEvent(submission.eventId)
|
||||
: null;
|
||||
|
||||
const nostrEventId: string | undefined = event?.id || submission.eventId || undefined;
|
||||
if (!nostrEventId) return null;
|
||||
|
||||
const eventTags: string[][] = Array.isArray(event?.tags) ? (event!.tags as string[][]) : [];
|
||||
const titleTag = eventTags.find((t) => t[0] === 'title')?.[1];
|
||||
const topicTags = eventTags
|
||||
.filter((t) => t[0] === 't' && t[1])
|
||||
.map((t) => (t[1] as string).toLowerCase());
|
||||
|
||||
const excerpt = ((event?.content as string) || '')
|
||||
.slice(0, 200)
|
||||
.replace(/[#*_\n]/g, '')
|
||||
.trim();
|
||||
|
||||
return {
|
||||
nostrEventId,
|
||||
naddr: submission.naddr || undefined,
|
||||
title: submission.title || titleTag || 'Untitled',
|
||||
excerpt: excerpt || undefined,
|
||||
authorPubkey: event?.pubkey || submission.authorPubkey,
|
||||
publishedAt: event?.created_at ?? null,
|
||||
tags: topicTags.length > 0 ? topicTags : undefined,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export default function AdminSubmissionsPage() {
|
||||
const [reviewingId, setReviewingId] = useState<string | null>(null);
|
||||
const [reviewNote, setReviewNote] = useState("");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const loadSubmissions = async () => {
|
||||
try {
|
||||
@@ -61,10 +62,17 @@ export default function AdminSubmissionsPage() {
|
||||
const handleReview = async (id: string, status: "APPROVED" | "REJECTED") => {
|
||||
setProcessing(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
await api.reviewSubmission(id, { status, reviewNote: reviewNote.trim() || undefined });
|
||||
const res = await api.reviewSubmission(id, {
|
||||
status,
|
||||
reviewNote: reviewNote.trim() || undefined,
|
||||
});
|
||||
setReviewingId(null);
|
||||
setReviewNote("");
|
||||
if (status === "APPROVED" && res?.post) {
|
||||
setSuccess(`Approved and published "${res.post.title}" to the blog.`);
|
||||
}
|
||||
await loadSubmissions();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
@@ -87,6 +95,7 @@ export default function AdminSubmissionsPage() {
|
||||
</div>
|
||||
|
||||
{error && <p className="text-error text-sm">{error}</p>}
|
||||
{success && <p className="text-primary text-sm">{success}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{TABS.map((tab) => (
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { buildBlogMarkdown } from "@/lib/llms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildBlogMarkdown();
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -3,14 +3,26 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Heart, Send } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, fetchLongformFromRelays, fetchEventFromRelays, type NostrProfile } from "@/lib/nostr";
|
||||
import {
|
||||
hasNostrExtension,
|
||||
getPublicKey,
|
||||
signEvent,
|
||||
publishEvent,
|
||||
shortenPubkey,
|
||||
fetchNostrProfile,
|
||||
fetchLongformFromRelays,
|
||||
fetchEventFromRelays,
|
||||
resolveEventFromRelays,
|
||||
type NostrProfile,
|
||||
} from "@/lib/nostr";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import type { Components } from "react-markdown";
|
||||
import { markdownComponents } from "./markdownComponents";
|
||||
import { remarkNostr } from "./remarkNostr";
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
@@ -34,84 +46,28 @@ interface NostrReply {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
const markdownComponents: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = className?.includes("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={`${className} block`}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
|
||||
),
|
||||
hr: () => <hr className="border-surface-container-high my-8" />,
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-left text-on-surface-variant">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2">{children}</td>
|
||||
),
|
||||
};
|
||||
// Builds a renderable Post from a raw long-form Nostr event, used when a slug is
|
||||
// a NIP-19 reference (naddr/nevent/note) that was never indexed by the backend.
|
||||
function postFromEvent(event: any, slug: string): Post {
|
||||
const tag = (name: string): string | undefined =>
|
||||
event.tags?.find((t: string[]) => t[0] === name)?.[1];
|
||||
const publishedAtSec = Number(tag("published_at")) || event.created_at;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
slug,
|
||||
title: tag("title") || "Untitled",
|
||||
content: event.content || "",
|
||||
excerpt: tag("summary"),
|
||||
authorPubkey: event.pubkey,
|
||||
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
|
||||
nostrEventId: event.id,
|
||||
naddr: slug.startsWith("naddr") ? slug : undefined,
|
||||
categories: (event.tags || [])
|
||||
.filter((t: string[]) => t[0] === "t" && t[1])
|
||||
.map((t: string[]) => ({ category: { id: t[1], name: t[1], slug: t[1] } })),
|
||||
};
|
||||
}
|
||||
|
||||
function ArticleSkeleton() {
|
||||
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
|
||||
@@ -137,6 +93,18 @@ function ArticleSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function RelayLoading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 py-16 text-on-surface-variant">
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium">Fetching from Nostr relays…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -150,6 +118,11 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
|
||||
const [liveContent, setLiveContent] = useState<string | null>(null);
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
|
||||
const [contentError, setContentError] = useState(false);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
|
||||
const retry = useCallback(() => setRetryKey((k) => k + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
setHasNostr(hasNostrExtension());
|
||||
@@ -157,39 +130,70 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLiveContent(null);
|
||||
setPost(null);
|
||||
setAuthorProfile(null);
|
||||
setContentError(false);
|
||||
setResolvingFromRelays(false);
|
||||
|
||||
// Loads the author's profile, then hydrates the article body from relays
|
||||
// when only metadata is present (indexed posts store an empty body).
|
||||
const hydrate = (data: Post) => {
|
||||
if (cancelled) return;
|
||||
setPost(data);
|
||||
setLoading(false);
|
||||
if (data?.authorPubkey) {
|
||||
fetchNostrProfile(data.authorPubkey)
|
||||
.then((profile) => !cancelled && setAuthorProfile(profile))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
|
||||
setLoadingContent(true);
|
||||
setContentError(false);
|
||||
const fetchPromise = data.naddr
|
||||
? fetchLongformFromRelays(data.naddr)
|
||||
: fetchEventFromRelays(data.nostrEventId!);
|
||||
fetchPromise
|
||||
.then((event) => {
|
||||
if (cancelled) return;
|
||||
if (event?.content) setLiveContent(event.content);
|
||||
else setContentError(true); // not found / timed out
|
||||
})
|
||||
.catch(() => !cancelled && setContentError(true))
|
||||
.finally(() => !cancelled && setLoadingContent(false));
|
||||
}
|
||||
};
|
||||
|
||||
api
|
||||
.getPost(slug)
|
||||
.then((data) => {
|
||||
setPost(data);
|
||||
setLoading(false);
|
||||
if (data?.authorPubkey) {
|
||||
fetchNostrProfile(data.authorPubkey)
|
||||
.then((profile) => setAuthorProfile(profile))
|
||||
.catch(() => {});
|
||||
}
|
||||
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
|
||||
setLoadingContent(true);
|
||||
const fetchPromise = data.naddr
|
||||
? fetchLongformFromRelays(data.naddr)
|
||||
: fetchEventFromRelays(data.nostrEventId!);
|
||||
fetchPromise
|
||||
.then((event) => {
|
||||
if (event?.content) {
|
||||
setLiveContent(event.content);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingContent(false));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
.then((data) => hydrate(data))
|
||||
.catch(() => {
|
||||
// Not indexed: treat the slug itself as a NIP-19 reference and resolve
|
||||
// the long-form note live from relays.
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
setResolvingFromRelays(true);
|
||||
resolveEventFromRelays(slug)
|
||||
.then((event) => {
|
||||
if (cancelled) return;
|
||||
setResolvingFromRelays(false);
|
||||
if (event) hydrate(postFromEvent(event, slug));
|
||||
else setError("Post not found");
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setResolvingFromRelays(false);
|
||||
setError(err?.message || "Post not found");
|
||||
});
|
||||
});
|
||||
}, [slug]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug, retryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
@@ -271,9 +275,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
|
||||
{loading && <ArticleSkeleton />}
|
||||
|
||||
{resolvingFromRelays && !post && <RelayLoading />}
|
||||
|
||||
{error && (
|
||||
<div className="bg-error-container/20 text-error rounded-xl p-6">
|
||||
Failed to load post: {error}
|
||||
<p className="mb-4">Failed to load post: {error}</p>
|
||||
<button
|
||||
onClick={retry}
|
||||
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -328,19 +340,29 @@ export default function BlogPostClient({ slug }: { slug: string }) {
|
||||
|
||||
<article className="mb-16">
|
||||
{loadingContent ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 bg-surface-container-high rounded"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
))}
|
||||
<RelayLoading />
|
||||
) : contentError && !(post.content || liveContent) ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-on-surface-variant mb-4">
|
||||
Couldn't fetch this article from the Nostr relays. It may be
|
||||
temporarily unavailable.
|
||||
</p>
|
||||
<button
|
||||
onClick={retry}
|
||||
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[remarkGfm, remarkNostr]}
|
||||
components={markdownComponents}
|
||||
// Preserve nostr: links (react-markdown strips unknown
|
||||
// schemes by default), so mentions/notes resolve correctly.
|
||||
urlTransform={(url) =>
|
||||
url.startsWith("nostr:") ? url : defaultUrlTransform(url)
|
||||
}
|
||||
>
|
||||
{post.content || liveContent || ""}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
loadNostrProfile,
|
||||
resolveEventFromRelays,
|
||||
shortenPubkey,
|
||||
type NostrProfile,
|
||||
} from "@/lib/nostr";
|
||||
|
||||
function njump(bech32: string): string {
|
||||
return `https://njump.me/${bech32}`;
|
||||
}
|
||||
|
||||
// Inline @mention chip: avatar + display name, resolved live from the author's
|
||||
// kind:0 profile. Falls back to a shortened pubkey until (or unless) it loads.
|
||||
export function NostrMention({ bech32 }: { bech32: string }) {
|
||||
const pubkey = useMemo(() => {
|
||||
try {
|
||||
const d = nip19.decode(bech32);
|
||||
if (d.type === "npub") return d.data as string;
|
||||
if (d.type === "nprofile") return (d.data as { pubkey: string }).pubkey;
|
||||
} catch {}
|
||||
return null;
|
||||
}, [bech32]);
|
||||
|
||||
const [profile, setProfile] = useState<NostrProfile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
let cancelled = false;
|
||||
loadNostrProfile(pubkey)
|
||||
.then((p) => !cancelled && setProfile(p))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey]);
|
||||
|
||||
// Show the username; fall back to a shortened npub only when no profile.
|
||||
const npub = useMemo(() => {
|
||||
if (bech32.startsWith("npub")) return bech32;
|
||||
return pubkey ? nip19.npubEncode(pubkey) : bech32;
|
||||
}, [bech32, pubkey]);
|
||||
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={njump(bech32)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 align-middle text-primary hover:underline font-medium no-underline"
|
||||
>
|
||||
{profile?.picture && (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
className="w-5 h-5 rounded-full object-cover bg-surface-container-high inline-block"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>@{name}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact author line used inside an embedded note.
|
||||
function NoteAuthor({ pubkey }: { pubkey: string }) {
|
||||
const [profile, setProfile] = useState<NostrProfile | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadNostrProfile(pubkey)
|
||||
.then((p) => !cancelled && setProfile(p))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pubkey]);
|
||||
const fallback = useMemo(() => {
|
||||
try {
|
||||
return shortenPubkey(nip19.npubEncode(pubkey));
|
||||
} catch {
|
||||
return shortenPubkey(pubkey);
|
||||
}
|
||||
}, [pubkey]);
|
||||
const name = profile?.name || profile?.displayName || fallback;
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{profile?.picture && (
|
||||
<img
|
||||
src={profile.picture}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded-full object-cover bg-surface-container-high"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="font-semibold text-on-surface text-sm">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Embedded referenced note (note/nevent/naddr). Renders as a bordered card.
|
||||
// Uses span/block elements (not <div>) so it stays valid when the reference
|
||||
// sits inside a markdown paragraph.
|
||||
export function NostrNote({ bech32 }: { bech32: string }) {
|
||||
const [event, setEvent] = useState<any>(null);
|
||||
const [state, setState] = useState<"loading" | "done" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState("loading");
|
||||
resolveEventFromRelays(bech32)
|
||||
.then((ev) => {
|
||||
if (cancelled) return;
|
||||
if (ev) {
|
||||
setEvent(ev);
|
||||
setState("done");
|
||||
} else {
|
||||
setState("error");
|
||||
}
|
||||
})
|
||||
.catch(() => !cancelled && setState("error"));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bech32]);
|
||||
|
||||
return (
|
||||
<span className="block my-4 rounded-xl border border-surface-container-high bg-surface-container-low p-4">
|
||||
{state === "loading" && (
|
||||
<span className="flex items-center gap-2 text-on-surface-variant text-sm">
|
||||
<span className="inline-block w-4 h-4 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
|
||||
Loading note…
|
||||
</span>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<a
|
||||
href={njump(bech32)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline text-sm break-all"
|
||||
>
|
||||
View referenced note on njump.me
|
||||
</a>
|
||||
)}
|
||||
{state === "done" && event && (
|
||||
<span className="block">
|
||||
<span className="flex items-center justify-between mb-3">
|
||||
<NoteAuthor pubkey={event.pubkey} />
|
||||
<a
|
||||
href={njump(bech32)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-on-surface-variant/60 hover:text-primary"
|
||||
>
|
||||
View note
|
||||
</a>
|
||||
</span>
|
||||
<span className="block whitespace-pre-wrap break-words text-on-surface-variant text-sm leading-relaxed">
|
||||
{(event.content || "").slice(0, 1000)}
|
||||
{(event.content || "").length > 1000 ? "…" : ""}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatches a `nostr:<bech32>` reference to the right embed by entity type.
|
||||
export function NostrEntity({ bech32 }: { bech32: string }) {
|
||||
let type: string;
|
||||
try {
|
||||
type = nip19.decode(bech32).type;
|
||||
} catch {
|
||||
return <>nostr:{bech32}</>;
|
||||
}
|
||||
if (type === "npub" || type === "nprofile") return <NostrMention bech32={bech32} />;
|
||||
if (type === "note" || type === "nevent" || type === "naddr") return <NostrNote bech32={bech32} />;
|
||||
return <>nostr:{bech32}</>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Components } from "react-markdown";
|
||||
import { NostrEntity } from "./NostrEmbeds";
|
||||
|
||||
// Shared markdown renderers for blog article bodies. The `remarkNostr` plugin
|
||||
// rewrites NIP-27 `nostr:` references into links with that scheme, which the
|
||||
// `a` renderer below swaps for live profile/note embeds.
|
||||
export const markdownComponents: Components = {
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
|
||||
),
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith("nostr:")) {
|
||||
return <NostrEntity bech32={href.slice("nostr:".length)} />;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = className?.includes("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={`${className} block`}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
|
||||
),
|
||||
hr: () => <hr className="border-surface-container-high my-8" />,
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="w-full text-left text-on-surface-variant">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2">{children}</td>
|
||||
),
|
||||
};
|
||||
@@ -30,7 +30,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
post.excerpt ||
|
||||
`Read "${post.title}" on the Belgian Bitcoin Embassy blog.`;
|
||||
const author = post.authorName || "Belgian Bitcoin Embassy";
|
||||
const ogImageUrl = `/og?title=${encodeURIComponent(post.title)}&type=blog`;
|
||||
// Prefer the post's own header image (Nostr `image` tag); fall back to a
|
||||
// generated OG card so every post still gets a real preview image.
|
||||
const ogImageUrl = post.image || `/og?title=${encodeURIComponent(post.title)}&type=blog`;
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
// Matches NIP-27 `nostr:` references (and bare bech32 entities) embedded in
|
||||
// article text: npub/nprofile (mentions) and note/nevent/naddr (notes).
|
||||
const NOSTR_RE =
|
||||
/(?:nostr:)?((?:npub|nprofile|note|nevent|naddr)1[023456789acdefghjklmnpqrstuvwxyz]+)/gi;
|
||||
|
||||
function isValidEntity(bech32: string): boolean {
|
||||
try {
|
||||
const { type } = nip19.decode(bech32);
|
||||
return ["npub", "nprofile", "note", "nevent", "naddr"].includes(type);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Splits a plain-text value into text + `link` nodes, where each link's url is
|
||||
// `nostr:<bech32>`. The markdown `a` renderer detects that scheme and swaps in
|
||||
// the live profile/note component.
|
||||
function splitText(value: string): any[] {
|
||||
const out: any[] = [];
|
||||
let last = 0;
|
||||
NOSTR_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = NOSTR_RE.exec(value)) !== null) {
|
||||
const bech32 = m[1];
|
||||
if (!isValidEntity(bech32)) continue;
|
||||
if (m.index > last) {
|
||||
out.push({ type: "text", value: value.slice(last, m.index) });
|
||||
}
|
||||
out.push({
|
||||
type: "link",
|
||||
url: `nostr:${bech32}`,
|
||||
children: [{ type: "text", value: m[0] }],
|
||||
});
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (out.length === 0) return [{ type: "text", value }];
|
||||
if (last < value.length) out.push({ type: "text", value: value.slice(last) });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remark plugin: walk the mdast tree and replace `nostr:` tokens inside text
|
||||
// nodes. Skips code and existing links so identifiers there are left untouched.
|
||||
export function remarkNostr() {
|
||||
return (tree: any) => {
|
||||
const walk = (node: any) => {
|
||||
if (!node || !Array.isArray(node.children)) return;
|
||||
const next: any[] = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type === "text") {
|
||||
next.push(...splitText(child.value));
|
||||
continue;
|
||||
}
|
||||
if (child.type !== "link" && child.type !== "inlineCode" && child.type !== "code") {
|
||||
walk(child);
|
||||
}
|
||||
next.push(child);
|
||||
}
|
||||
node.children = next;
|
||||
};
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
+40
-252
@@ -1,109 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import {
|
||||
BlogIndex,
|
||||
type BlogPost,
|
||||
type BlogCategory,
|
||||
} from "@/components/public/BlogIndex";
|
||||
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
author?: string;
|
||||
authorPubkey?: string;
|
||||
publishedAt?: string;
|
||||
createdAt?: string;
|
||||
categories?: { category: { id: string; name: string; slug: string } }[];
|
||||
featured?: boolean;
|
||||
const LIMIT = 9;
|
||||
|
||||
// Render at request time so the first page of posts is in the HTML for crawlers.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
const res = await fetch(apiUrl(path), { cache: "no-store" });
|
||||
if (!res.ok) return fallback;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
function PostCardSkeleton() {
|
||||
return (
|
||||
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
|
||||
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
|
||||
</div>
|
||||
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-full bg-surface-container-high rounded" />
|
||||
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<div className="h-4 w-32 bg-surface-container-high rounded" />
|
||||
<div className="h-4 w-24 bg-surface-container-high rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedPostSkeleton() {
|
||||
return (
|
||||
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse mb-12">
|
||||
<div className="p-8 md:p-12 space-y-4">
|
||||
<div className="h-5 w-24 bg-surface-container-high rounded-full" />
|
||||
<div className="h-10 w-2/3 bg-surface-container-high rounded" />
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
<div className="h-4 w-full bg-surface-container-high rounded" />
|
||||
<div className="h-4 w-full bg-surface-container-high rounded" />
|
||||
<div className="h-4 w-1/2 bg-surface-container-high rounded" />
|
||||
</div>
|
||||
<div className="h-4 w-48 bg-surface-container-high rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogPage() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [activeCategory, setActiveCategory] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const limit = 9;
|
||||
|
||||
useEffect(() => {
|
||||
api.getCategories().then(setCategories).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getPosts({
|
||||
category: activeCategory === "all" ? undefined : activeCategory,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
.then(({ posts: data, total: t }) => {
|
||||
setPosts(data);
|
||||
setTotal(t);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [activeCategory, page]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const featured = posts.find((p) => p.featured);
|
||||
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
|
||||
export default async function BlogPage() {
|
||||
const [{ posts, total }, categories] = await Promise.all([
|
||||
fetchJson<{ posts: BlogPost[]; total: number }>(`/posts?page=1&limit=${LIMIT}`, {
|
||||
posts: [],
|
||||
total: 0,
|
||||
}),
|
||||
fetchJson<BlogCategory[]>("/categories", []),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Blog", href: "/blog" },
|
||||
]}
|
||||
/>
|
||||
<Navbar />
|
||||
|
||||
<div className="min-h-screen">
|
||||
@@ -121,160 +57,12 @@ export default function BlogPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-8 mb-12">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => { setActiveCategory("all"); setPage(1); }}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeCategory === "all"
|
||||
? "bg-primary text-on-primary"
|
||||
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => { setActiveCategory(cat.slug); setPage(1); }}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeCategory === cat.slug
|
||||
? "bg-primary text-on-primary"
|
||||
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-8 pb-24">
|
||||
{error && (
|
||||
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
|
||||
Failed to load posts: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<>
|
||||
<FeaturedPostSkeleton />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<PostCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-2xl font-bold text-on-surface-variant mb-2">
|
||||
No posts yet
|
||||
</p>
|
||||
<p className="text-on-surface-variant/60">
|
||||
Check back soon for curated Bitcoin content.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{featured && page === 1 && (
|
||||
<Link
|
||||
href={`/blog/${featured.slug}`}
|
||||
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<div className="p-8 md:p-12">
|
||||
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
|
||||
Featured
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
|
||||
{featured.title}
|
||||
</h2>
|
||||
{featured.excerpt && (
|
||||
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
|
||||
{featured.author && <span>{featured.author}</span>}
|
||||
{featured.publishedAt && (
|
||||
<span>{formatDate(featured.publishedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{regularPosts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
{post.categories && post.categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{post.categories.map((pc) => (
|
||||
<span
|
||||
key={pc.category.id}
|
||||
className="text-primary text-[10px] uppercase tracking-widest font-bold"
|
||||
>
|
||||
{pc.category.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
|
||||
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
|
||||
{post.author && <span>{post.author}</span>}
|
||||
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
|
||||
{(post.publishedAt || post.createdAt) && (
|
||||
<span>
|
||||
{formatDate(post.publishedAt || post.createdAt!)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
|
||||
Read <ArrowRight size={12} />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 mt-16">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowLeft size={16} /> Previous
|
||||
</button>
|
||||
<span className="text-sm text-on-surface-variant">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next <ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<BlogIndex
|
||||
initialPosts={Array.isArray(posts) ? posts : []}
|
||||
initialTotal={total ?? 0}
|
||||
categories={Array.isArray(categories) ? categories : []}
|
||||
limit={LIMIT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Message Board — Pay with Lightning",
|
||||
@@ -12,5 +13,15 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function BoardLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Board", href: "/board" },
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { buildCommunityMarkdown } from "@/lib/llms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildCommunityMarkdown();
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Community - Connect with Belgian Bitcoiners",
|
||||
@@ -13,5 +14,15 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function CommunityLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Community", href: "/community" },
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { buildContactMarkdown } from "@/lib/llms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildContactMarkdown();
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { ContactChannelGrid } from "@/components/public/ContactChannelGrid";
|
||||
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contact Us",
|
||||
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Contact", href: "/contact" },
|
||||
]}
|
||||
/>
|
||||
<Navbar />
|
||||
<div className="min-h-screen">
|
||||
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { buildEventsMarkdown } from "@/lib/llms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildEventsMarkdown();
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import EventDetailClient from "./EventDetailClient";
|
||||
import { EventJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { getMeetupStartUtc, getMeetupEndUtc } from "@/lib/meetupEventTime";
|
||||
|
||||
async function fetchEvent(id: string) {
|
||||
try {
|
||||
@@ -59,6 +60,15 @@ export default async function EventDetailPage({ params }: Props) {
|
||||
const event = await fetchEvent(id);
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
||||
|
||||
let startDate = event?.date;
|
||||
let endDate: string | undefined;
|
||||
if (event?.date) {
|
||||
const start = getMeetupStartUtc(event.date, event.time || "00:00");
|
||||
if (!Number.isNaN(start.getTime())) startDate = start.toISOString();
|
||||
const end = getMeetupEndUtc(event.date, event.time || "");
|
||||
if (end && !Number.isNaN(end.getTime())) endDate = end.toISOString();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{event && (
|
||||
@@ -66,7 +76,8 @@ export default async function EventDetailPage({ params }: Props) {
|
||||
<EventJsonLd
|
||||
name={event.title}
|
||||
description={event.description}
|
||||
startDate={event.date}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
location={event.location}
|
||||
url={`${siteUrl}/events/${id}`}
|
||||
imageUrl={event.imageId ? `${siteUrl}/media/${event.imageId}` : undefined}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Events - Bitcoin Meetups in Belgium",
|
||||
@@ -13,5 +14,15 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function EventsLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Events", href: "/events" },
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { MeetupCard } from "@/components/public/MeetupCard";
|
||||
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
||||
import { fetchMeetupsLive, partitionMeetups } from "@/lib/meetupsData";
|
||||
|
||||
function 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>
|
||||
);
|
||||
}
|
||||
// Render at request time from the live backend so crawlers (no JS) always get
|
||||
// the real list, and the count stays in lock-step with /events.md and /llms.txt.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function EventsPage() {
|
||||
const [meetups, setMeetups] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getMeetups()
|
||||
.then((data: any) => {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setMeetups(list);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const now = new Date();
|
||||
const upcoming = meetups.filter((m) => {
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start >= now;
|
||||
});
|
||||
const past = meetups
|
||||
.filter((m) => {
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
if (Number.isNaN(start.getTime())) return false;
|
||||
return start < now;
|
||||
})
|
||||
.reverse();
|
||||
export default async function EventsPage() {
|
||||
const meetups = await fetchMeetupsLive();
|
||||
const { upcoming, past } = partitionMeetups(meetups);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -74,18 +30,16 @@ export default function EventsPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
|
||||
{error && (
|
||||
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
|
||||
Failed to load events: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-8 pb-24 space-y-20"
|
||||
data-upcoming-count={upcoming.length}
|
||||
data-past-count={past.length}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-xl font-black flex items-center gap-3">
|
||||
Upcoming
|
||||
{!loading && upcoming.length > 0 && (
|
||||
{upcoming.length > 0 && (
|
||||
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
|
||||
{upcoming.length}
|
||||
</span>
|
||||
@@ -94,11 +48,7 @@ export default function EventsPage() {
|
||||
<AddToCalendarButton />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{[0, 1, 2].map((i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : upcoming.length === 0 ? (
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="border border-zinc-800/60 rounded-xl px-8 py-12 text-center">
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
No upcoming events scheduled. Check back soon.
|
||||
@@ -106,26 +56,23 @@ export default function EventsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{upcoming.map((m) => <MeetupCard key={m.id} meetup={m} />)}
|
||||
{upcoming.map((m) => (
|
||||
<MeetupCard key={m.id} meetup={m} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(loading || past.length > 0) && (
|
||||
{past.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-black mb-8 text-on-surface-variant/60">
|
||||
Past Events
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{[0, 1, 2].map((i) => <CardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{past.map((m) => <MeetupCard key={m.id} meetup={m} muted />)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{past.map((m) => (
|
||||
<MeetupCard key={m.id} meetup={m} muted />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { buildFaqMarkdown } from "@/lib/llms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildFaqMarkdown();
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
+31
-70
@@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { FaqPageJsonLd } from "@/components/public/JsonLd";
|
||||
import { FaqAccordion } from "@/components/public/FaqAccordion";
|
||||
import { FaqPageJsonLd, BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
|
||||
interface FaqItem {
|
||||
id: string;
|
||||
@@ -16,25 +12,37 @@ interface FaqItem {
|
||||
showOnHomepage: boolean;
|
||||
}
|
||||
|
||||
export default function FaqPage() {
|
||||
const [items, setItems] = useState<FaqItem[]>([]);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Render at request time from the live backend so the Q&A and FAQPage JSON-LD
|
||||
// are always present in the HTML for crawlers, never a build-time-empty shell.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
useEffect(() => {
|
||||
api.getFaqsAll()
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setItems(data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
async function fetchFaqs(): Promise<FaqItem[]> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/faqs?all=true"), { cache: "no-store" });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function FaqPage() {
|
||||
const items = await fetchFaqs();
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.length > 0 && (
|
||||
<FaqPageJsonLd items={items.map((i) => ({ question: i.question, answer: i.answer }))} />
|
||||
<FaqPageJsonLd
|
||||
items={items.map((i) => ({ question: i.question, answer: i.answer }))}
|
||||
/>
|
||||
)}
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "FAQ", href: "/faq" },
|
||||
]}
|
||||
/>
|
||||
<Navbar />
|
||||
<div className="min-h-screen">
|
||||
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
|
||||
@@ -43,57 +51,10 @@ export default function FaqPage() {
|
||||
Everything you need to know about the Belgian Bitcoin Embassy.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="bg-surface-container-low rounded-xl h-[72px] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && items.length === 0 && (
|
||||
{items.length === 0 ? (
|
||||
<p className="text-on-surface-variant">No FAQs available yet.</p>
|
||||
)}
|
||||
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{items.map((item, i) => {
|
||||
const isOpen = openIndex === i;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-surface-container-low rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenIndex(isOpen ? null : i)}
|
||||
className="w-full flex items-center justify-between p-6 text-left"
|
||||
>
|
||||
<span className="text-lg font-bold pr-4">{item.question}</span>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={cn(
|
||||
"shrink-0 text-primary transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-200",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<FaqAccordion items={items} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { buildHomeMarkdown } from "@/lib/llms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildHomeMarkdown();
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
+5
-13
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import { ClientProviders } from "@/components/providers/ClientProviders";
|
||||
import { OrganizationJsonLd, WebSiteJsonLd } from "@/components/public/JsonLd";
|
||||
import { fetchPublicSettings, socialUrlsFromSettings } from "@/lib/seo";
|
||||
import "./globals.css";
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
||||
@@ -24,17 +25,6 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description:
|
||||
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
|
||||
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",
|
||||
@@ -90,7 +80,9 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const settings = await fetchPublicSettings();
|
||||
const sameAs = socialUrlsFromSettings(settings);
|
||||
return (
|
||||
<html lang="en" dir="ltr" className="dark">
|
||||
<body>
|
||||
@@ -102,7 +94,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
) : null}
|
||||
<OrganizationJsonLd />
|
||||
<OrganizationJsonLd sameAs={sameAs} />
|
||||
<WebSiteJsonLd />
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { buildLlmsTxt } from "@/lib/llms";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const body = await buildLlmsTxt();
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PRIVACY_MARKDOWN } from "@/lib/content/legalMarkdown";
|
||||
|
||||
export async function GET() {
|
||||
return new Response(PRIVACY_MARKDOWN, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy",
|
||||
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Privacy Policy", href: "/privacy" },
|
||||
]}
|
||||
/>
|
||||
<Navbar />
|
||||
<div className="min-h-screen">
|
||||
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TERMS_MARKDOWN } from "@/lib/content/legalMarkdown";
|
||||
|
||||
export async function GET() {
|
||||
return new Response(TERMS_MARKDOWN, {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/public/Navbar";
|
||||
import { Footer } from "@/components/public/Footer";
|
||||
import { BreadcrumbJsonLd } from "@/components/public/JsonLd";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Use",
|
||||
@@ -18,6 +19,12 @@ export const metadata: Metadata = {
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbJsonLd
|
||||
items={[
|
||||
{ name: "Home", href: "/" },
|
||||
{ name: "Terms of Use", href: "/terms" },
|
||||
]}
|
||||
/>
|
||||
<Navbar />
|
||||
<div className="min-h-screen">
|
||||
<div className="max-w-3xl mx-auto px-8 pt-16 pb-24">
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ArrowLeft, ChevronRight } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
author?: string;
|
||||
authorPubkey?: string;
|
||||
publishedAt?: string;
|
||||
createdAt?: string;
|
||||
categories?: { category: { id: string; name: string; slug: string } }[];
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export interface BlogCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface BlogIndexProps {
|
||||
initialPosts: BlogPost[];
|
||||
initialTotal: number;
|
||||
categories: BlogCategory[];
|
||||
limit: number;
|
||||
}
|
||||
|
||||
function PostCardSkeleton() {
|
||||
return (
|
||||
<div className="bg-surface-container-low rounded-xl overflow-hidden animate-pulse">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
|
||||
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
|
||||
</div>
|
||||
<div className="h-7 w-3/4 bg-surface-container-high rounded" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-full bg-surface-container-high rounded" />
|
||||
<div className="h-4 w-2/3 bg-surface-container-high rounded" />
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<div className="h-4 w-32 bg-surface-container-high rounded" />
|
||||
<div className="h-4 w-24 bg-surface-container-high rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlogIndex({
|
||||
initialPosts,
|
||||
initialTotal,
|
||||
categories,
|
||||
limit,
|
||||
}: BlogIndexProps) {
|
||||
const [posts, setPosts] = useState<BlogPost[]>(initialPosts);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [activeCategory, setActiveCategory] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isFirst = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// The initial ("all", page 1) data is already rendered from the server.
|
||||
if (isFirst.current) {
|
||||
isFirst.current = false;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.getPosts({
|
||||
category: activeCategory === "all" ? undefined : activeCategory,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
.then(({ posts: data, total: t }) => {
|
||||
if (cancelled) return;
|
||||
setPosts(data);
|
||||
setTotal(t);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeCategory, page, limit]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const featured = posts.find((p) => p.featured);
|
||||
const regularPosts = featured ? posts.filter((p) => p.id !== featured.id) : posts;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-7xl mx-auto px-8 mb-12">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveCategory("all");
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeCategory === "all"
|
||||
? "bg-primary text-on-primary"
|
||||
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => {
|
||||
setActiveCategory(cat.slug);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeCategory === cat.slug
|
||||
? "bg-primary text-on-primary"
|
||||
: "bg-surface-container-high text-on-surface hover:bg-surface-bright"
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-8 pb-24">
|
||||
{error && (
|
||||
<div className="bg-error-container/20 text-error rounded-xl p-6 mb-8">
|
||||
Failed to load posts: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<PostCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-2xl font-bold text-on-surface-variant mb-2">
|
||||
No posts yet
|
||||
</p>
|
||||
<p className="text-on-surface-variant/60">
|
||||
Check back soon for curated Bitcoin content.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{featured && page === 1 && (
|
||||
<Link
|
||||
href={`/blog/${featured.slug}`}
|
||||
className="block bg-surface-container-low rounded-xl overflow-hidden mb-12 group hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
<div className="p-8 md:p-12">
|
||||
<span className="inline-block px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full mb-6">
|
||||
Featured
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-4xl font-black tracking-tight mb-4 group-hover:text-primary transition-colors">
|
||||
{featured.title}
|
||||
</h2>
|
||||
{featured.excerpt && (
|
||||
<p className="text-on-surface-variant text-lg leading-relaxed max-w-2xl mb-6">
|
||||
{featured.excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-on-surface-variant/60">
|
||||
{featured.author && <span>{featured.author}</span>}
|
||||
{featured.publishedAt && (
|
||||
<span>{formatDate(featured.publishedAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{regularPosts.map((post) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
{post.categories && post.categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{post.categories.map((pc) => (
|
||||
<span
|
||||
key={pc.category.id}
|
||||
className="text-primary text-[10px] uppercase tracking-widest font-bold"
|
||||
>
|
||||
{pc.category.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="font-bold text-base mb-3 leading-snug group-hover:text-primary transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="text-on-surface-variant text-sm leading-relaxed mb-5 flex-1 line-clamp-3">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-auto pt-4 border-t border-zinc-800/60">
|
||||
<div className="flex items-center gap-2 text-xs text-on-surface-variant/50">
|
||||
{post.author && <span>{post.author}</span>}
|
||||
{post.author && (post.publishedAt || post.createdAt) && <span>·</span>}
|
||||
{(post.publishedAt || post.createdAt) && (
|
||||
<span>{formatDate(post.publishedAt || post.createdAt!)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-primary text-xs font-semibold flex items-center gap-1.5 group-hover:gap-2.5 transition-all">
|
||||
Read <ArrowRight size={12} />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 mt-16">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowLeft size={16} /> Previous
|
||||
</button>
|
||||
<span className="text-sm text-on-surface-variant">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-surface-container-high text-on-surface font-medium transition-colors hover:bg-surface-bright disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next <ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface FaqAccordionItem {
|
||||
id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive accordion. Items are passed in from the server so the full
|
||||
* question + answer text is present in the initial HTML (indexable), while the
|
||||
* open/close behaviour is hydrated on the client.
|
||||
*/
|
||||
export function FaqAccordion({ items }: { items: FaqAccordionItem[] }) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item, i) => {
|
||||
const isOpen = openIndex === i;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-surface-container-low rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenIndex(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
className="w-full flex items-center justify-between p-6 text-left"
|
||||
>
|
||||
<span className="text-lg font-bold pr-4">{item.question}</span>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={cn(
|
||||
"shrink-0 text-primary transition-transform duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-200",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<p className="px-6 pb-6 text-on-surface-variant leading-relaxed">
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,18 +14,21 @@ export function JsonLd({ data }: JsonLdProps) {
|
||||
const siteUrl =
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
||||
|
||||
export function OrganizationJsonLd() {
|
||||
export function OrganizationJsonLd({ sameAs }: { sameAs?: string[] } = {}) {
|
||||
const sameAsUrls =
|
||||
sameAs && sameAs.length > 0 ? sameAs : ["https://t.me/belgianbitcoinembassy"];
|
||||
return (
|
||||
<JsonLd
|
||||
data={{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"@id": `${siteUrl}/#organization`,
|
||||
name: "Belgian Bitcoin Embassy",
|
||||
url: siteUrl,
|
||||
logo: `${siteUrl}/og-default.png`,
|
||||
description:
|
||||
"Discover Bitcoin meetups across Belgium. Real conversations, education, and a strong local community.",
|
||||
sameAs: ["https://t.me/belgianbitcoinembassy"],
|
||||
sameAs: sameAsUrls,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
addressLocality: "Antwerp",
|
||||
@@ -103,6 +106,7 @@ interface EventJsonLdProps {
|
||||
name: string;
|
||||
description?: string;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
location?: string;
|
||||
url: string;
|
||||
imageUrl?: string;
|
||||
@@ -114,6 +118,7 @@ export function EventJsonLd({
|
||||
name,
|
||||
description,
|
||||
startDate,
|
||||
endDate,
|
||||
location,
|
||||
url,
|
||||
imageUrl,
|
||||
@@ -130,6 +135,7 @@ export function EventJsonLd({
|
||||
name,
|
||||
description: description || `Bitcoin meetup: ${name}`,
|
||||
startDate,
|
||||
...(endDate ? { endDate } : {}),
|
||||
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
||||
eventStatus: "https://schema.org/EventScheduled",
|
||||
...(location
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Lightweight TTL cache backed by localStorage so resolved Nostr data (author
|
||||
// profiles, long-form events) survives page reloads and navigation instead of
|
||||
// being re-queried from relays every visit. No-ops on the server and degrades
|
||||
// silently on quota/parse errors so it can never break rendering.
|
||||
|
||||
const PREFIX = "bbe:nostr:";
|
||||
|
||||
interface Entry<T> {
|
||||
v: T;
|
||||
t: number; // stored-at epoch ms
|
||||
}
|
||||
|
||||
export function readCache<T>(key: string, ttlMs: number): T | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PREFIX + key);
|
||||
if (!raw) return null;
|
||||
const entry = JSON.parse(raw) as Entry<T>;
|
||||
if (Date.now() - entry.t > ttlMs) {
|
||||
window.localStorage.removeItem(PREFIX + key);
|
||||
return null;
|
||||
}
|
||||
return entry.v;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCache<T>(key: string, value: T): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const payload = JSON.stringify({ v: value, t: Date.now() } satisfies Entry<T>);
|
||||
try {
|
||||
window.localStorage.setItem(PREFIX + key, payload);
|
||||
} catch {
|
||||
// Quota exceeded (or similar): evict our namespace and retry once.
|
||||
try {
|
||||
pruneNamespace();
|
||||
window.localStorage.setItem(PREFIX + key, payload);
|
||||
} catch {
|
||||
// Give up silently — caching is best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removes every entry written by this cache to recover space when localStorage
|
||||
// is full. Only touches our own namespace.
|
||||
function pruneNamespace(): void {
|
||||
for (let i = window.localStorage.length - 1; i >= 0; i--) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (k && k.startsWith(PREFIX)) window.localStorage.removeItem(k);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Plain-markdown mirrors of the legal pages, served at /privacy.md and /terms.md
|
||||
* for llms.txt consumers.
|
||||
*
|
||||
* SOURCE OF TRUTH: these must be kept in sync with the rendered pages at
|
||||
* app/privacy/page.tsx and app/terms/page.tsx. If you edit the legal copy there,
|
||||
* update it here too (and bump "Last updated").
|
||||
*/
|
||||
|
||||
export const PRIVACY_MARKDOWN = `# Privacy Policy
|
||||
|
||||
_Last updated: April 3, 2026_
|
||||
|
||||
## Who We Are
|
||||
|
||||
Belgian Bitcoin Embassy is a community initiative focused on Bitcoin education and meetups in Belgium. We aim to process the minimum data needed to run this website safely and reliably.
|
||||
|
||||
## What Data We Process
|
||||
|
||||
If you log in with Nostr, we process your public key, role, and optional username. We also process content needed to operate the site, such as posts, submissions, media metadata, and moderation records. Some Nostr-related data may be cached on our servers to improve performance.
|
||||
|
||||
## Why We Process Data
|
||||
|
||||
We process data to provide core site features, maintain account sessions, prevent abuse, moderate community interactions, and keep the service secure. Our legal bases are contract (or steps requested by you before using features) and legitimate interests (security, integrity, and service operation).
|
||||
|
||||
## Cookies and Local Storage
|
||||
|
||||
We currently do not use third-party analytics or advertising cookies. We do use browser local storage to keep your authentication session active. You can clear this data at any time by logging out or clearing browser storage.
|
||||
|
||||
## Recipients
|
||||
|
||||
When you interact through Nostr, your actions are published on the Nostr network, which is public by design. We may also use infrastructure providers to host and secure the website.
|
||||
|
||||
## Retention
|
||||
|
||||
We keep account and operational data only as long as needed for service operation, security, and moderation. Technical logs may be retained for a limited period. You can remove local browser data at any time.
|
||||
|
||||
## Your GDPR Rights
|
||||
|
||||
Depending on applicable law, you may have rights to access, rectify, erase, restrict, object to, or request portability of your personal data. You also have the right to lodge a complaint with the Belgian Data Protection Authority.
|
||||
|
||||
## International Transfers
|
||||
|
||||
If technical providers process data outside the EEA, we aim to rely on appropriate safeguards as required under GDPR.
|
||||
|
||||
## Children
|
||||
|
||||
This website is not directed at children under the age of 16.
|
||||
|
||||
## Policy Updates
|
||||
|
||||
We may update this Privacy Policy from time to time. Material changes are reflected by updating the date at the top of this page.
|
||||
|
||||
## Contact
|
||||
|
||||
For privacy-related questions, reach out to us via our [community channels](https://belgianbitcoinembassy.org/community.md).
|
||||
`;
|
||||
|
||||
export const TERMS_MARKDOWN = `# Terms of Use
|
||||
|
||||
_Last updated: April 3, 2026_
|
||||
|
||||
## Acceptance and Changes
|
||||
|
||||
By accessing or using this website, you agree to these Terms of Use. We may update these terms from time to time, and continued use after updates means you accept the revised terms.
|
||||
|
||||
## Nature of the Service
|
||||
|
||||
This website provides general Bitcoin education and community information. Nothing on this website is financial, investment, legal, or tax advice. We do not make recommendations to buy, sell, or hold Bitcoin or any other crypto-asset. Content is general in nature and not tailored to your personal circumstances.
|
||||
|
||||
## Crypto Risk Warning
|
||||
|
||||
Crypto-assets are highly volatile and you can lose all of your money. Crypto-assets are not regulated in the same way as traditional financial products. Regulatory rules may change, and availability may differ by jurisdiction. Always do your own research and consult a qualified professional before making financial decisions.
|
||||
|
||||
## MiCA and Regulatory Position
|
||||
|
||||
Belgian Bitcoin Embassy presents this website as an educational platform and not as a crypto-asset service provider. If the nature of our activities changes, we may update these terms and related legal pages.
|
||||
|
||||
## Content and Third Parties
|
||||
|
||||
Some content is curated from the Nostr network. We do not claim ownership of third-party content. Local moderation may hide or limit content on this site, but does not change content on the Nostr network itself.
|
||||
|
||||
## User Conduct
|
||||
|
||||
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.
|
||||
|
||||
## Paid and Commercial Features
|
||||
|
||||
Certain features may involve Lightning payments, such as paid public board messages. Any such feature is optional and does not change the educational nature of the site.
|
||||
|
||||
## Affiliate and Sponsorship Transparency
|
||||
|
||||
As of the last updated date above, we do not earn referral fees from links on this website. If sponsored or affiliate content is added in the future, it will be clearly disclosed.
|
||||
|
||||
## Disclaimer and Liability
|
||||
|
||||
This platform is provided on an "as is" and "as available" basis without warranties of any kind. To the maximum extent permitted by law, Belgian Bitcoin Embassy is not liable for losses or damages resulting from your use of this site or reliance on its content.
|
||||
|
||||
## Governing Law
|
||||
|
||||
These terms are governed by Belgian law, without prejudice to mandatory consumer protections that apply in your jurisdiction.
|
||||
|
||||
## Contact
|
||||
|
||||
For terms-related questions, contact us through our [community channels](https://belgianbitcoinembassy.org/community.md).
|
||||
`;
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Server-side builders for the llms.txt file (llmstxt.org) and the plain-markdown
|
||||
* page mirrors (`<path>.md`). Everything here is generated at request time from
|
||||
* live data (settings, meetups, FAQs, posts) so it never goes stale.
|
||||
*
|
||||
* Mirrors deliberately omit nav, footer, and the legal disclaimer block — an LLM
|
||||
* reading these needs the actual content, not repeated chrome.
|
||||
*/
|
||||
import { apiUrl } from "./api-base";
|
||||
import { formatMeetupCivilDateLong } from "./meetupEventTime";
|
||||
import { fetchMeetupsLive, partitionMeetups, countUpcoming } from "./meetupsData";
|
||||
|
||||
export const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL || "https://belgianbitcoinembassy.org";
|
||||
|
||||
// Live (uncached) so the mirrors always reflect the same backend state as the
|
||||
// rendered pages — no ISR drift between /events, /events.md, and /llms.txt.
|
||||
async function fetchJson<T>(path: string, fallback: T): Promise<T> {
|
||||
try {
|
||||
const res = await fetch(apiUrl(path), { cache: "no-store" });
|
||||
if (!res.ok) return fallback;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPostDate(value?: string): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleDateString("en-GB", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
/** Social channels with their human description, in display order. */
|
||||
const SOCIAL_CHANNELS: { key: string; name: string; description: string }[] = [
|
||||
{
|
||||
key: "telegram_link",
|
||||
name: "Telegram",
|
||||
description:
|
||||
"Main Belgian chat group for daily discussion and local coordination.",
|
||||
},
|
||||
{
|
||||
key: "nostr_link",
|
||||
name: "Nostr",
|
||||
description:
|
||||
"Follow the BBE on the censorship-resistant social protocol.",
|
||||
},
|
||||
{
|
||||
key: "x_link",
|
||||
name: "X",
|
||||
description: "Latest local announcements and event drops.",
|
||||
},
|
||||
{
|
||||
key: "youtube_link",
|
||||
name: "YouTube",
|
||||
description: "Past talks, educational content, and meetup recordings.",
|
||||
},
|
||||
{
|
||||
key: "discord_link",
|
||||
name: "Discord",
|
||||
description:
|
||||
"Technical discussions, node running, and project collaboration.",
|
||||
},
|
||||
{
|
||||
key: "linkedin_link",
|
||||
name: "LinkedIn",
|
||||
description: "The Belgian Bitcoin professional network.",
|
||||
},
|
||||
];
|
||||
|
||||
function configuredChannels(settings: Record<string, string>) {
|
||||
return SOCIAL_CHANNELS.map((c) => ({ ...c, url: settings[c.key]?.trim() }))
|
||||
.filter((c): c is typeof c & { url: string } =>
|
||||
!!c.url && /^https?:\/\//i.test(c.url),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// llms.txt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildLlmsTxt(): Promise<string> {
|
||||
const [settings, meetups] = await Promise.all([
|
||||
fetchJson<Record<string, string>>("/settings/public", {}),
|
||||
fetchMeetupsLive(),
|
||||
]);
|
||||
|
||||
const upcomingCount = countUpcoming(meetups);
|
||||
|
||||
const channels = configuredChannels(settings);
|
||||
const channelNames = channels.map((c) => c.name).join(", ");
|
||||
|
||||
const communityDescription = channelNames
|
||||
? `How to connect on ${channelNames}`
|
||||
: "How to connect with the community";
|
||||
|
||||
// Always state the count (including zero) so it stays machine-parseable and
|
||||
// verifiably consistent with /events and /events.md.
|
||||
const upcomingLine =
|
||||
upcomingCount > 0
|
||||
? `There ${upcomingCount === 1 ? "is" : "are"} currently ${upcomingCount} upcoming meetup${
|
||||
upcomingCount === 1 ? "" : "s"
|
||||
} scheduled.`
|
||||
: "There are currently 0 upcoming meetups scheduled; the community meets monthly.";
|
||||
|
||||
return `# Belgian Bitcoin Embassy
|
||||
|
||||
> A sovereign, non-commercial community organizing monthly Bitcoin meetups in Antwerp. Education, technical discussion, and adoption. Not a company.
|
||||
|
||||
Belgian Bitcoin Embassy is a volunteer-run network, not a business. Content here covers meetups, FAQs about the community, and curated Bitcoin/Nostr commentary. ${upcomingLine}
|
||||
|
||||
## Pages
|
||||
|
||||
- [About](${SITE_URL}/index.html.md): Who we are and what we do
|
||||
- [Events](${SITE_URL}/events.md): Upcoming and past Bitcoin meetups in Belgium
|
||||
- [FAQ](${SITE_URL}/faq.md): Common questions about the community and how to get involved
|
||||
- [Blog](${SITE_URL}/blog.md): Curated Bitcoin and Nostr content
|
||||
- [Community](${SITE_URL}/community.md): ${communityDescription}
|
||||
|
||||
## Optional
|
||||
|
||||
- [Privacy](${SITE_URL}/privacy.md)
|
||||
- [Terms](${SITE_URL}/terms.md)
|
||||
- [Contact](${SITE_URL}/contact.md)
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page mirrors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildHomeMarkdown(): Promise<string> {
|
||||
const meetups = await fetchMeetupsLive();
|
||||
const next = partitionMeetups(meetups).upcoming[0];
|
||||
|
||||
let nextSection = "";
|
||||
if (next) {
|
||||
const when = formatMeetupCivilDateLong(next.date);
|
||||
const bits = [when, next.time, next.location].filter(Boolean).join(" · ");
|
||||
nextSection = `
|
||||
|
||||
## Next Meetup
|
||||
|
||||
**${next.title}** — ${bits}
|
||||
|
||||
See all events: ${SITE_URL}/events.md`;
|
||||
}
|
||||
|
||||
return `# Belgian Bitcoin Embassy
|
||||
|
||||
> A sovereign, non-commercial community organizing monthly Bitcoin meetups in Antwerp. Education, technical discussion, and adoption. Not a company.
|
||||
|
||||
## The Mission
|
||||
|
||||
"Fix the money, fix the world."
|
||||
|
||||
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.${nextSection}
|
||||
|
||||
## More
|
||||
|
||||
- Events: ${SITE_URL}/events.md
|
||||
- FAQ: ${SITE_URL}/faq.md
|
||||
- Blog: ${SITE_URL}/blog.md
|
||||
- Community: ${SITE_URL}/community.md
|
||||
`;
|
||||
}
|
||||
|
||||
export async function buildEventsMarkdown(): Promise<string> {
|
||||
const meetups = await fetchMeetupsLive();
|
||||
const { upcoming, past } = partitionMeetups(meetups);
|
||||
|
||||
const renderMeetup = (m: any): string => {
|
||||
const when = formatMeetupCivilDateLong(m.date);
|
||||
const meta = [when, m.time, m.location].filter(Boolean).join(" · ");
|
||||
const organizer = m.organizer?.name || "Belgian Bitcoin Embassy";
|
||||
const lines = [`### ${m.title}`, "", `${meta}`, "", `Organized by ${organizer}.`];
|
||||
if (m.description) lines.push("", m.description.trim());
|
||||
lines.push("", `Details: ${SITE_URL}/events/${m.id}`);
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const sections: string[] = [
|
||||
"# Events",
|
||||
"",
|
||||
"Past and upcoming Bitcoin meetups in Belgium, organized by the Belgian Bitcoin Embassy.",
|
||||
];
|
||||
|
||||
sections.push("", "## Upcoming");
|
||||
sections.push(
|
||||
"",
|
||||
upcoming.length
|
||||
? upcoming.map(renderMeetup).join("\n\n")
|
||||
: "No upcoming events are currently scheduled. Check back soon.",
|
||||
);
|
||||
|
||||
if (past.length) {
|
||||
sections.push("", "## Past Events", "", past.map(renderMeetup).join("\n\n"));
|
||||
}
|
||||
|
||||
return sections.join("\n") + "\n";
|
||||
}
|
||||
|
||||
export async function buildFaqMarkdown(): Promise<string> {
|
||||
const faqs = await fetchJson<any[]>("/faqs?all=true", []);
|
||||
const list = Array.isArray(faqs) ? faqs : [];
|
||||
|
||||
const header = `# Frequently Asked Questions
|
||||
|
||||
Everything you need to know about the Belgian Bitcoin Embassy.`;
|
||||
|
||||
if (!list.length) {
|
||||
return `${header}\n\nNo FAQs are available yet.\n`;
|
||||
}
|
||||
|
||||
const body = list
|
||||
.map((f) => `## ${f.question}\n\n${(f.answer || "").trim()}`)
|
||||
.join("\n\n");
|
||||
|
||||
return `${header}\n\n${body}\n`;
|
||||
}
|
||||
|
||||
export async function buildBlogMarkdown(): Promise<string> {
|
||||
const data = await fetchJson<{ posts: any[]; total: number }>(
|
||||
"/posts?limit=100",
|
||||
{ posts: [], total: 0 },
|
||||
);
|
||||
const posts = Array.isArray(data?.posts) ? data.posts : [];
|
||||
|
||||
const header = `# Blog
|
||||
|
||||
Curated Bitcoin and Nostr content from the Belgian Bitcoin Embassy.`;
|
||||
|
||||
if (!posts.length) {
|
||||
return `${header}\n\nNo posts have been published yet.\n`;
|
||||
}
|
||||
|
||||
const body = posts
|
||||
.map((p) => {
|
||||
const date = formatPostDate(p.publishedAt || p.createdAt);
|
||||
const meta = [p.author, date].filter(Boolean).join(" · ");
|
||||
const lines = [`## ${p.title}`];
|
||||
if (meta) lines.push("", `_${meta}_`);
|
||||
if (p.excerpt) lines.push("", p.excerpt.trim());
|
||||
lines.push("", `Read: ${SITE_URL}/blog/${p.slug}`);
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return `${header}\n\n${body}\n`;
|
||||
}
|
||||
|
||||
export async function buildCommunityMarkdown(): Promise<string> {
|
||||
const settings = await fetchJson<Record<string, string>>(
|
||||
"/settings/public",
|
||||
{},
|
||||
);
|
||||
const channels = configuredChannels(settings);
|
||||
|
||||
const header = `# Community
|
||||
|
||||
Connect with local Belgian Bitcoiners, builders, and educators across every platform.`;
|
||||
|
||||
if (!channels.length) {
|
||||
return `${header}\n\nCommunity channel links are being set up. Check back soon.\n`;
|
||||
}
|
||||
|
||||
const body = channels
|
||||
.map((c) => `- [${c.name}](${c.url}): ${c.description}`)
|
||||
.join("\n");
|
||||
|
||||
return `${header}\n\n## Channels\n\n${body}\n`;
|
||||
}
|
||||
|
||||
export async function buildContactMarkdown(): Promise<string> {
|
||||
const settings = await fetchJson<Record<string, string>>(
|
||||
"/settings/public",
|
||||
{},
|
||||
);
|
||||
const channels = configuredChannels(settings);
|
||||
|
||||
const header = `# Contact
|
||||
|
||||
The best way to reach us is through our community channels. We are a decentralized community — there is no central office or email inbox.`;
|
||||
|
||||
const lines: string[] = [header, "", "## Channels"];
|
||||
if (channels.length) {
|
||||
lines.push(
|
||||
"",
|
||||
...channels.map((c) => `- [${c.name}](${c.url}): ${c.description}`),
|
||||
);
|
||||
} else {
|
||||
lines.push("", "Community channel links are being set up. Check back soon.");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"## Meetups",
|
||||
"",
|
||||
`The best way to connect is in person. Come to our monthly meetup — see upcoming events at ${SITE_URL}/events.md`,
|
||||
);
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
@@ -52,6 +52,27 @@ export function getMeetupStartUtc(dateStr: string, timeStr: string): Date {
|
||||
return new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the event end instant in UTC when the time string carries a range
|
||||
* (e.g. "18:00 - 21:00"), otherwise null. Used for schema.org Event.endDate.
|
||||
*/
|
||||
export function getMeetupEndUtc(dateStr: string, timeStr: string): Date | null {
|
||||
const key = normalizeMeetupDateKey(dateStr);
|
||||
if (!key) return null;
|
||||
const parts = key.split("-").map(Number);
|
||||
const year = parts[0];
|
||||
const month = parts[1];
|
||||
const day = parts[2];
|
||||
if (!year || !month || !day) return null;
|
||||
|
||||
const timeParts = (timeStr?.trim() || "").split(/\s*[-–]\s*/);
|
||||
if (timeParts.length < 2 || !timeParts[1]?.trim()) return null;
|
||||
|
||||
const { h: endH, m: endM } = parseLocalTime(timeParts[1]);
|
||||
const utcEndH = endH - BRUSSELS_OFFSET_HOURS;
|
||||
return new Date(Date.UTC(year, month - 1, day, utcEndH, endM, 0));
|
||||
}
|
||||
|
||||
const UTC_CAL_OPTS = { timeZone: "UTC" } as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Single source of truth for meetup data used by the public /events page, the
|
||||
* /events.md mirror, and the /llms.txt summary line. Centralizing the fetch +
|
||||
* upcoming/past partition guarantees those three can never disagree on the count.
|
||||
*
|
||||
* Fetched with `no-store` so every render reflects the live backend — this is
|
||||
* what keeps crawlers (which don't run JS) and llms.txt consumers from seeing a
|
||||
* stale or build-time-empty list.
|
||||
*/
|
||||
import { apiUrl } from "./api-base";
|
||||
import { getMeetupStartUtc } from "./meetupEventTime";
|
||||
|
||||
export interface Meetup {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
time?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
organizer?: { name?: string; slug?: string } | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Fetch all publicly-visible meetups from the backend, live (uncached). */
|
||||
export async function fetchMeetupsLive(): Promise<Meetup[]> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/meetups"), { cache: "no-store" });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? (data as Meetup[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PartitionedMeetups {
|
||||
upcoming: Meetup[];
|
||||
past: Meetup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Split meetups into upcoming (start >= now, soonest first) and past (start <
|
||||
* now, most recent first). Meetups with an unparseable date are dropped.
|
||||
*/
|
||||
export function partitionMeetups(
|
||||
meetups: Meetup[],
|
||||
now: Date = new Date(),
|
||||
): PartitionedMeetups {
|
||||
const upcoming: Meetup[] = [];
|
||||
const past: Meetup[] = [];
|
||||
|
||||
for (const m of meetups) {
|
||||
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
||||
if (Number.isNaN(start.getTime())) continue;
|
||||
if (start >= now) upcoming.push(m);
|
||||
else past.push(m);
|
||||
}
|
||||
|
||||
const startMs = (m: Meetup) =>
|
||||
getMeetupStartUtc(m.date, m.time || "00:00").getTime();
|
||||
upcoming.sort((a, b) => startMs(a) - startMs(b));
|
||||
past.sort((a, b) => startMs(b) - startMs(a));
|
||||
|
||||
return { upcoming, past };
|
||||
}
|
||||
|
||||
/** Number of upcoming meetups — the single value llms.txt and events.md share. */
|
||||
export function countUpcoming(meetups: Meetup[], now: Date = new Date()): number {
|
||||
return partitionMeetups(meetups, now).upcoming.length;
|
||||
}
|
||||
+192
-57
@@ -1,5 +1,14 @@
|
||||
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { readCache, writeCache } from "./browserCache";
|
||||
|
||||
// Persistent (localStorage) cache lifetimes. Longer than the in-memory TTLs:
|
||||
// these survive reloads, where the whole point is to avoid re-querying relays.
|
||||
// Profiles and immutable events change rarely; addressable (replaceable)
|
||||
// long-form events use a shorter window so edits are picked up reasonably soon.
|
||||
const PROFILE_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
const EVENT_PERSIST_TTL = 6 * 60 * 60 * 1000; // 6 hours (events by id are immutable)
|
||||
const LONGFORM_PERSIST_TTL = 30 * 60 * 1000; // 30 minutes (replaceable)
|
||||
|
||||
// Relays return events keyed by hex pubkeys and only accept hex in `authors`
|
||||
// filters. Pubkeys may be stored/passed as npub (or nprofile), so normalize.
|
||||
@@ -87,6 +96,72 @@ const FALLBACK_RELAYS = [
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
// Only secure WebSocket (wss://) relays may be dialed: the site is served over
|
||||
// HTTPS, so ws:// endpoints (e.g. ws://umbrel.local) are blocked as mixed
|
||||
// content and must never be connected to. Relay hints reach us from untrusted
|
||||
// sources (naddr hints, NIP-65 lists, user/extension relay configs), so filter
|
||||
// at every connection point.
|
||||
export function isSecureRelay(url: unknown): url is string {
|
||||
return typeof url === "string" && /^wss:\/\//i.test(url.trim());
|
||||
}
|
||||
|
||||
export function filterSecureRelays(urls: Iterable<string>): string[] {
|
||||
const out: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (isSecureRelay(url)) out.push(url.trim());
|
||||
}
|
||||
return [...new Set(out)];
|
||||
}
|
||||
|
||||
// How long a single relay query may run before we give up on it. Relays go
|
||||
// dead or stall mid-subscription without ever sending EOSE, which is what makes
|
||||
// blog loads occasionally hang; bounding every query keeps the page responsive.
|
||||
const RELAY_MAX_WAIT = 5000; // ms, passed to nostr-tools as `maxWait`
|
||||
const RELAY_HARD_TIMEOUT = 8000; // ms, absolute ceiling incl. connection setup
|
||||
|
||||
// A single shared read pool for the whole app. Reusing one pool keeps relay
|
||||
// websockets warm and—crucially—avoids opening several simultaneous
|
||||
// connections to the same relays (author profile + article body + NIP-65 all at
|
||||
// once on a blog open), which relays throttle/drop and which caused the
|
||||
// intermittent "couldn't fetch" failure on first load. Never closed: the pool
|
||||
// manages and reuses its own connections for the SPA's lifetime.
|
||||
let _readPool: any = null;
|
||||
async function getReadPool(): Promise<any> {
|
||||
if (!_readPool) {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
_readPool = new SimplePool();
|
||||
}
|
||||
return _readPool;
|
||||
}
|
||||
|
||||
// Resolves to `fallback` if `promise` hasn't settled within `ms`. Used as a
|
||||
// hard ceiling around relay queries so a connection that never opens (or never
|
||||
// closes) can't block rendering.
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(fallback), ms);
|
||||
promise.then(
|
||||
(v) => { clearTimeout(timer); resolve(v); },
|
||||
() => { clearTimeout(timer); resolve(fallback); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// A single bounded query across `relays` (which are dialed in parallel by the
|
||||
// pool). Returns null on timeout, empty relay set, or error.
|
||||
async function getEventBounded(
|
||||
pool: { get: (relays: string[], filter: any, params?: { maxWait?: number }) => Promise<any> },
|
||||
relays: string[],
|
||||
filter: any,
|
||||
): Promise<any | null> {
|
||||
if (relays.length === 0) return null;
|
||||
return withTimeout(
|
||||
pool.get(relays, filter, { maxWait: RELAY_MAX_WAIT }),
|
||||
RELAY_HARD_TIMEOUT,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
let _siteRelaysCache: { relays: string[]; fetchedAt: number } | null = null;
|
||||
const SITE_RELAY_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -98,9 +173,10 @@ export async function getSiteRelays(): Promise<string[]> {
|
||||
const res = await fetch("/api/relays/public");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.relays) && data.relays.length > 0) {
|
||||
_siteRelaysCache = { relays: data.relays, fetchedAt: Date.now() };
|
||||
return data.relays;
|
||||
const secure = Array.isArray(data.relays) ? filterSecureRelays(data.relays) : [];
|
||||
if (secure.length > 0) {
|
||||
_siteRelaysCache = { relays: secure, fetchedAt: Date.now() };
|
||||
return secure;
|
||||
}
|
||||
} catch {}
|
||||
return FALLBACK_RELAYS;
|
||||
@@ -119,12 +195,11 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
||||
const cached = _nip65Cache.get(pubkey);
|
||||
if (cached && Date.now() - cached.fetchedAt < NIP65_TTL) return cached.data;
|
||||
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const siteRelays = await getSiteRelays();
|
||||
const pool = new SimplePool();
|
||||
const pool = await getReadPool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(siteRelays, {
|
||||
const event = await getEventBounded(pool, siteRelays, {
|
||||
kinds: [10002],
|
||||
authors: [pubkey],
|
||||
});
|
||||
@@ -138,6 +213,7 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== "r" || !tag[1]) continue;
|
||||
const url = tag[1];
|
||||
if (!isSecureRelay(url)) continue;
|
||||
const marker = tag[2];
|
||||
result.all.push(url);
|
||||
if (marker === "write") {
|
||||
@@ -154,8 +230,6 @@ export async function fetchNip65RelayList(pubkey: string): Promise<Nip65RelayLis
|
||||
return result;
|
||||
} catch {
|
||||
return { write: [], read: [], all: [] };
|
||||
} finally {
|
||||
pool.close(siteRelays);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,8 +249,9 @@ export async function getUserStoredRelays(): Promise<{ url: string; read: boolea
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
_userRelaysCache = { relays: data, fetchedAt: Date.now() };
|
||||
return data;
|
||||
const secure = data.filter((r) => isSecureRelay(r?.url));
|
||||
_userRelaysCache = { relays: secure, fetchedAt: Date.now() };
|
||||
return secure;
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
@@ -217,7 +292,7 @@ export async function publishEvent(signedEvent: any): Promise<void> {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const relayUrls = [...allRelays];
|
||||
const relayUrls = filterSecureRelays(allRelays);
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
|
||||
@@ -233,30 +308,40 @@ export async function fetchNostrProfile(
|
||||
const hex = toHexPubkey(pubkey);
|
||||
if (!hex) return {};
|
||||
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const allRelays = new Set<string>(relayUrls || await getSiteRelays());
|
||||
PROFILE_METADATA_RELAYS.forEach((url) => allRelays.add(url));
|
||||
const cached = readProfileCache(hex, Date.now());
|
||||
if (cached) return cached;
|
||||
|
||||
const filter = { kinds: [0], authors: [hex] };
|
||||
|
||||
// Overlap the NIP-65 lookup with the first query rather than waiting on it.
|
||||
const nip65Promise = fetchNip65RelayList(hex).catch(
|
||||
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
|
||||
);
|
||||
|
||||
const pool = await getReadPool();
|
||||
const tried = new Set<string>();
|
||||
try {
|
||||
const nip65 = await fetchNip65RelayList(hex);
|
||||
nip65.write.forEach((url) => allRelays.add(url));
|
||||
} catch {}
|
||||
// Phase 1: provided relays (or site relays) + profile aggregators.
|
||||
const phase1 = filterSecureRelays([
|
||||
...(relayUrls || await getSiteRelays()),
|
||||
...PROFILE_METADATA_RELAYS,
|
||||
]);
|
||||
phase1.forEach((u) => tried.add(u));
|
||||
let event = await getEventBounded(pool, phase1, filter);
|
||||
|
||||
const urls = [...allRelays];
|
||||
const pool = new SimplePool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(urls, {
|
||||
kinds: [0],
|
||||
authors: [hex],
|
||||
});
|
||||
// Phase 2: author's NIP-65 write relays if not found yet.
|
||||
if (!event?.content) {
|
||||
const nip65 = await nip65Promise;
|
||||
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
|
||||
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
|
||||
}
|
||||
|
||||
if (!event?.content) return {};
|
||||
return parseProfileContent(event.content);
|
||||
const profile = parseProfileContent(event.content);
|
||||
writeProfileCache(hex, profile, Date.now());
|
||||
return profile;
|
||||
} catch {
|
||||
return {};
|
||||
} finally {
|
||||
pool.close(urls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,13 +366,25 @@ function isProfileEmpty(p: NostrProfile): boolean {
|
||||
|
||||
function readProfileCache(pubkey: string, now: number): NostrProfile | null {
|
||||
const cached = _profileCache.get(pubkey);
|
||||
if (!cached) return null;
|
||||
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
|
||||
return now - cached.fetchedAt < ttl ? cached.profile : null;
|
||||
if (cached) {
|
||||
const ttl = cached.empty ? PROFILE_EMPTY_TTL : PROFILE_TTL;
|
||||
if (now - cached.fetchedAt < ttl) return cached.profile;
|
||||
}
|
||||
// Fall back to the persistent browser cache (survives reloads/navigation).
|
||||
// Only resolved profiles are persisted, so a hit here is always non-empty.
|
||||
const persisted = readCache<NostrProfile>(`profile:${pubkey}`, PROFILE_PERSIST_TTL);
|
||||
if (persisted) {
|
||||
_profileCache.set(pubkey, { profile: persisted, fetchedAt: now, empty: false });
|
||||
return persisted;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeProfileCache(pubkey: string, profile: NostrProfile, now: number): void {
|
||||
_profileCache.set(pubkey, { profile, fetchedAt: now, empty: isProfileEmpty(profile) });
|
||||
const empty = isProfileEmpty(profile);
|
||||
_profileCache.set(pubkey, { profile, fetchedAt: now, empty });
|
||||
// Persist resolved profiles only; skip empties so a reload can retry the miss.
|
||||
if (!empty) writeCache(`profile:${pubkey}`, profile);
|
||||
}
|
||||
|
||||
// Batched profile fetch. Resolves kind:0 metadata for many pubkeys using a
|
||||
@@ -321,10 +418,9 @@ export async function fetchNostrProfiles(
|
||||
|
||||
if (hexByKey.size === 0) return result;
|
||||
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const base = relayUrls && relayUrls.length > 0 ? relayUrls : await getSiteRelays();
|
||||
const urls = [...new Set([...base, ...PROFILE_METADATA_RELAYS])];
|
||||
const pool = new SimplePool();
|
||||
const urls = filterSecureRelays([...base, ...PROFILE_METADATA_RELAYS]);
|
||||
const pool = await getReadPool();
|
||||
|
||||
try {
|
||||
const authors = [...new Set(hexByKey.values())];
|
||||
@@ -363,8 +459,6 @@ export async function fetchNostrProfiles(
|
||||
if (!(key in result)) result[key] = {};
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
pool.close(urls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,22 +511,27 @@ export function loadNostrProfile(pubkey: string): Promise<NostrProfile> {
|
||||
}
|
||||
|
||||
export async function fetchEventFromRelays(eventId: string): Promise<any | null> {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const cacheKey = `event:${eventId}`;
|
||||
const cached = readCache<any>(cacheKey, EVENT_PERSIST_TTL);
|
||||
if (cached) return cached;
|
||||
|
||||
const siteRelays = await getSiteRelays();
|
||||
const pool = new SimplePool();
|
||||
const pool = await getReadPool();
|
||||
|
||||
try {
|
||||
const event = await pool.get(siteRelays, { ids: [eventId] });
|
||||
const event = await getEventBounded(pool, siteRelays, { ids: [eventId] });
|
||||
if (event) writeCache(cacheKey, event);
|
||||
return event || null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
pool.close(siteRelays);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLongformFromRelays(naddrStr: string): Promise<any | null> {
|
||||
const { SimplePool } = await import("nostr-tools/pool");
|
||||
const cacheKey = `longform:${naddrStr}`;
|
||||
const cached = readCache<any>(cacheKey, LONGFORM_PERSIST_TTL);
|
||||
if (cached) return cached;
|
||||
|
||||
const { nip19 } = await import("nostr-tools");
|
||||
|
||||
let decoded: { kind: number; pubkey: string; identifier: string; relays?: string[] };
|
||||
@@ -445,29 +544,65 @@ export async function fetchLongformFromRelays(naddrStr: string): Promise<any | n
|
||||
}
|
||||
|
||||
const siteRelays = await getSiteRelays();
|
||||
const naddrRelays = decoded.relays || [];
|
||||
const allRelays = new Set<string>([...naddrRelays, ...siteRelays]);
|
||||
|
||||
try {
|
||||
const nip65 = await fetchNip65RelayList(decoded.pubkey);
|
||||
nip65.write.forEach((url) => allRelays.add(url));
|
||||
} catch {}
|
||||
|
||||
const relayUrls = [...allRelays];
|
||||
const naddrRelays = filterSecureRelays(decoded.relays || []);
|
||||
const filter = {
|
||||
kinds: [decoded.kind],
|
||||
authors: [decoded.pubkey],
|
||||
"#d": [decoded.identifier],
|
||||
};
|
||||
|
||||
const pool = new SimplePool();
|
||||
// Kick off the author's NIP-65 lookup immediately so it overlaps the first
|
||||
// query instead of adding a serial round-trip before it.
|
||||
const nip65Promise = fetchNip65RelayList(decoded.pubkey).catch(
|
||||
() => ({ write: [], read: [], all: [] }) as Nip65RelayList,
|
||||
);
|
||||
|
||||
const pool = await getReadPool();
|
||||
const tried = new Set<string>();
|
||||
// Phase 1: naddr relay hints + site relays, all dialed in parallel. The
|
||||
// hints usually point at the author's own relay, so this is the fast path.
|
||||
const phase1 = filterSecureRelays([...naddrRelays, ...siteRelays]);
|
||||
phase1.forEach((u) => tried.add(u));
|
||||
let event = await getEventBounded(pool, phase1, filter);
|
||||
|
||||
// Phase 2: fall back to the author's NIP-65 write relays only if needed.
|
||||
if (!event) {
|
||||
const nip65 = await nip65Promise;
|
||||
const phase2 = filterSecureRelays(nip65.write).filter((u) => !tried.has(u));
|
||||
if (phase2.length > 0) event = await getEventBounded(pool, phase2, filter);
|
||||
}
|
||||
|
||||
if (event) writeCache(cacheKey, event);
|
||||
return event || null;
|
||||
}
|
||||
|
||||
// Resolves any NIP-19 reference (naddr / nevent / note) or a raw 64-char hex
|
||||
// event id to its underlying Nostr event by querying relays. Lets the blog page
|
||||
// render a long-form note straight from a shared link even when it was never
|
||||
// indexed by the backend. Returns null if it can't be decoded or found.
|
||||
export async function resolveEventFromRelays(identifier: string): Promise<any | null> {
|
||||
const trimmed = identifier.trim();
|
||||
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return fetchEventFromRelays(trimmed.toLowerCase());
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
const event = await pool.get(relayUrls, filter);
|
||||
return event || null;
|
||||
decoded = nip19.decode(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
pool.close(relayUrls);
|
||||
}
|
||||
|
||||
switch (decoded.type) {
|
||||
case "naddr":
|
||||
return fetchLongformFromRelays(trimmed);
|
||||
case "nevent":
|
||||
return fetchEventFromRelays((decoded.data as { id: string }).id);
|
||||
case "note":
|
||||
return fetchEventFromRelays(decoded.data as string);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { apiUrl } from "./api-base";
|
||||
|
||||
/**
|
||||
* Server-side fetch of the public site settings (social links, titles, …).
|
||||
* Cached/revalidated so it doesn't hit the backend on every render.
|
||||
*/
|
||||
export async function fetchPublicSettings(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const res = await fetch(apiUrl("/settings/public"), {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
return (await res.json()) as Record<string, string>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Setting keys that hold a public social/profile URL, in display order. */
|
||||
const SOCIAL_SETTING_KEYS = [
|
||||
"telegram_link",
|
||||
"nostr_link",
|
||||
"x_link",
|
||||
"youtube_link",
|
||||
"discord_link",
|
||||
"linkedin_link",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Build the list of real social URLs for schema.org `sameAs` from settings.
|
||||
* Only includes entries that are actually configured (non-empty, http(s)).
|
||||
*/
|
||||
export function socialUrlsFromSettings(
|
||||
settings: Record<string, string>,
|
||||
): string[] {
|
||||
return SOCIAL_SETTING_KEYS.map((key) => settings[key]?.trim())
|
||||
.filter((url): url is string => !!url && /^https?:\/\//i.test(url));
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"check:consistency": "node scripts/check-consistency.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Consistency check: confirms the number of upcoming meetups reported by the
|
||||
* live /events page, the /events.md mirror, and the /llms.txt summary all match.
|
||||
*
|
||||
* Catches the exact "confidently wrong" drift this check exists to prevent:
|
||||
* a mirror or summary going stale relative to the rendered page.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-consistency.mjs [baseUrl]
|
||||
* BASE_URL=https://belgianbitcoinembassy.org node scripts/check-consistency.mjs
|
||||
*
|
||||
* Exits 0 if all three agree, 1 on any mismatch or fetch failure.
|
||||
*/
|
||||
|
||||
const baseUrl = (
|
||||
process.argv[2] ||
|
||||
process.env.BASE_URL ||
|
||||
"http://localhost:3000"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
async function getText(path) {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "bbe-consistency-check" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET ${url} -> ${res.status}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/** /llms.txt: "There are currently N upcoming meetup(s) scheduled." */
|
||||
function parseLlmsTxt(text) {
|
||||
const m = text.match(/currently\s+(\d+)\s+upcoming meetup/i);
|
||||
if (!m) throw new Error("llms.txt: could not find upcoming-meetup count line");
|
||||
return Number(m[1]);
|
||||
}
|
||||
|
||||
/** /events.md: count "### " headings within the "## Upcoming" section. */
|
||||
function parseEventsMd(text) {
|
||||
const lines = text.split("\n");
|
||||
let inUpcoming = false;
|
||||
let count = 0;
|
||||
let sawUpcoming = false;
|
||||
for (const line of lines) {
|
||||
if (/^##\s+Upcoming\b/.test(line)) {
|
||||
inUpcoming = true;
|
||||
sawUpcoming = true;
|
||||
continue;
|
||||
}
|
||||
if (/^##\s+/.test(line) && inUpcoming) inUpcoming = false; // next section
|
||||
if (inUpcoming && /^###\s+/.test(line)) count += 1;
|
||||
}
|
||||
if (!sawUpcoming) throw new Error("events.md: no '## Upcoming' section found");
|
||||
return count;
|
||||
}
|
||||
|
||||
/** /events: data-upcoming-count attribute rendered into the HTML. */
|
||||
function parseEventsHtml(text) {
|
||||
const m = text.match(/data-upcoming-count="(\d+)"/);
|
||||
if (!m) throw new Error("events: data-upcoming-count attribute not found in HTML");
|
||||
return Number(m[1]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [llmsTxt, eventsMd, eventsHtml] = await Promise.all([
|
||||
getText("/llms.txt"),
|
||||
getText("/events.md"),
|
||||
getText("/events"),
|
||||
]);
|
||||
|
||||
const counts = {
|
||||
"/llms.txt": parseLlmsTxt(llmsTxt),
|
||||
"/events.md": parseEventsMd(eventsMd),
|
||||
"/events": parseEventsHtml(eventsHtml),
|
||||
};
|
||||
|
||||
const values = Object.values(counts);
|
||||
const allMatch = values.every((v) => v === values[0]);
|
||||
|
||||
console.log(`Consistency check against ${baseUrl}`);
|
||||
for (const [name, value] of Object.entries(counts)) {
|
||||
console.log(` ${name.padEnd(12)} upcoming = ${value}`);
|
||||
}
|
||||
|
||||
if (!allMatch) {
|
||||
console.error(
|
||||
`\n✗ MISMATCH: upcoming-meetup counts disagree (${values.join(", ")}).`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n✓ All sources agree: ${values[0]} upcoming meetup(s).`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`✗ Consistency check failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user