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