first commit

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

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

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

159
frontend/lib/nostr.ts Normal file
View File

@@ -0,0 +1,159 @@
import { generateSecretKey, getPublicKey as getPubKeyFromSecret } from "nostr-tools/pure";
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: any): Promise<any>;
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>;
};
}
}
export type BunkerSignerInterface = {
getPublicKey(): Promise<string>;
signEvent(event: any): Promise<any>;
close(): Promise<void>;
};
export function hasNostrExtension(): boolean {
return typeof window !== "undefined" && !!window.nostr;
}
export async function getPublicKey(): Promise<string> {
if (!window.nostr) throw new Error("No Nostr extension found");
return window.nostr.getPublicKey();
}
export async function signEvent(event: any): Promise<any> {
if (!window.nostr) throw new Error("No Nostr extension found");
return window.nostr.signEvent(event);
}
export function createAuthEvent(pubkey: string, challenge: string) {
return {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["relay", ""],
["challenge", challenge],
],
content: "",
pubkey,
};
}
export function shortenPubkey(pubkey: string): string {
if (!pubkey) return "";
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
}
export interface NostrProfile {
name?: string;
picture?: string;
about?: string;
nip05?: string;
displayName?: string;
}
const DEFAULT_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.nostr.band",
];
export async function publishEvent(signedEvent: any): Promise<void> {
const { SimplePool } = await import("nostr-tools/pool");
let relayUrls: string[] = DEFAULT_RELAYS;
try {
if (window.nostr?.getRelays) {
const ext = await window.nostr.getRelays();
const write = Object.entries(ext)
.filter(([, p]) => (p as any).write)
.map(([url]) => url);
if (write.length > 0) relayUrls = write;
}
} catch {}
const pool = new SimplePool();
try {
await Promise.allSettled(pool.publish(relayUrls, signedEvent));
} finally {
pool.close(relayUrls);
}
}
export async function fetchNostrProfile(
pubkey: string,
relayUrls: string[] = DEFAULT_RELAYS
): Promise<NostrProfile> {
const { SimplePool } = await import("nostr-tools/pool");
const pool = new SimplePool();
try {
const event = await pool.get(relayUrls, {
kinds: [0],
authors: [pubkey],
});
if (!event?.content) return {};
const meta = JSON.parse(event.content);
return {
name: meta.name || meta.display_name,
displayName: meta.display_name,
picture: meta.picture,
about: meta.about,
nip05: meta.nip05,
};
} catch {
return {};
} finally {
pool.close(relayUrls);
}
}
// NIP-46: External signer (bunker:// URI)
export async function createBunkerSigner(
input: string
): Promise<{ signer: BunkerSignerInterface; pubkey: string }> {
const { BunkerSigner, parseBunkerInput } = await import("nostr-tools/nip46");
const bp = await parseBunkerInput(input);
if (!bp) throw new Error("Invalid bunker URI or NIP-05 identifier");
const clientSecretKey = generateSecretKey();
const signer = BunkerSigner.fromBunker(clientSecretKey, bp);
await signer.connect();
const pubkey = await signer.getPublicKey();
return { signer, pubkey };
}
// NIP-46: Generate a nostrconnect:// URI for QR display
export async function generateNostrConnectSetup(
relayUrls: string[] = DEFAULT_RELAYS.slice(0, 2)
): Promise<{ uri: string; clientSecretKey: Uint8Array }> {
const { createNostrConnectURI } = await import("nostr-tools/nip46");
const clientSecretKey = generateSecretKey();
const clientPubkey = getPubKeyFromSecret(clientSecretKey);
const secretBytes = crypto.getRandomValues(new Uint8Array(8));
const secret = Array.from(secretBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const uri = createNostrConnectURI({
clientPubkey,
relays: relayUrls,
secret,
name: "Belgian Bitcoin Embassy",
});
return { uri, clientSecretKey };
}
// NIP-46: Wait for a remote signer to connect via nostrconnect:// URI
export async function waitForNostrConnectSigner(
clientSecretKey: Uint8Array,
uri: string,
signal?: AbortSignal
): Promise<{ signer: BunkerSignerInterface; pubkey: string }> {
const { BunkerSigner } = await import("nostr-tools/nip46");
const signer = await BunkerSigner.fromURI(clientSecretKey, uri, {}, signal);
const pubkey = await signer.getPublicKey();
return { signer, pubkey };
}

22
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,22 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date) {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function slugify(text: string) {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}