Ignore local storage; admin users NIP-05, media, events, footer updates

- Add /storage/ and /backend/storage/ to .gitignore
- Track meetup time helper, logo asset, and assorted frontend/backend fixes
This commit is contained in:
bbe
2026-04-02 22:13:28 +02:00
parent 2fa378c360
commit 2ddf6495fb
13 changed files with 405 additions and 38 deletions

View File

@@ -14,8 +14,8 @@ JWT_SECRET=change-me-to-a-random-secret-in-production
BACKEND_PORT=4000
FRONTEND_URL=http://localhost:3000
# Media storage
MEDIA_STORAGE_PATH=./storage/media
# Media storage (path relative to repo root, or use an absolute path in production)
MEDIA_STORAGE_PATH=storage/media
# Frontend (public)
NEXT_PUBLIC_API_URL=http://localhost:4000/api

4
.gitignore vendored
View File

@@ -42,4 +42,8 @@ coverage/
# Misc
.turbo
# Local uploads / media blobs (not versioned)
/storage/
/backend/storage/
deploy/

View File

@@ -7,8 +7,12 @@ import slugify from 'slugify';
import { prisma } from '../db/prisma';
import { requireAuth, requireRole } from '../middleware/auth';
const REPO_ROOT = path.resolve(__dirname, '../../..');
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH
|| path.resolve(__dirname, '../../../storage/media');
? path.isAbsolute(process.env.MEDIA_STORAGE_PATH)
? process.env.MEDIA_STORAGE_PATH
: path.resolve(REPO_ROOT, process.env.MEDIA_STORAGE_PATH)
: path.resolve(REPO_ROOT, 'storage/media');
function ensureStorageDir() {
fs.mkdirSync(STORAGE_PATH, { recursive: true });

View File

@@ -22,12 +22,17 @@ function getBlockedUsernames(): Set<string> {
const USERNAME_REGEX = /^[a-z0-9._-]+$/i;
function validateUsername(username: string): string | null {
function validateUsername(
username: string,
opts?: { allowReserved?: boolean }
): string | null {
if (!username || username.trim().length === 0) return 'Username is required';
if (username.length > 50) return 'Username must be 50 characters or fewer';
if (!USERNAME_REGEX.test(username)) return 'Username may only contain letters, numbers, dots, hyphens, and underscores';
const blocked = getBlockedUsernames();
if (blocked.has(username.toLowerCase())) return 'This username is reserved';
if (!opts?.allowReserved) {
const blocked = getBlockedUsernames();
if (blocked.has(username.toLowerCase())) return 'This username is reserved';
}
return null;
}
@@ -174,4 +179,58 @@ router.patch(
}
);
router.patch(
'/:pubkey',
requireAuth,
requireRole(['ADMIN']),
async (req: Request, res: Response) => {
try {
const pubkeyRaw = req.params.pubkey;
const pubkey =
typeof pubkeyRaw === 'string' ? pubkeyRaw : Array.isArray(pubkeyRaw) ? pubkeyRaw[0] : '';
if (!pubkey) {
res.status(400).json({ error: 'pubkey is required' });
return;
}
const { username } = req.body;
const normalized = (username as string || '').trim().toLowerCase();
const error = validateUsername(normalized, { allowReserved: true });
if (error) {
res.status(400).json({ error });
return;
}
const target = await prisma.user.findUnique({ where: { pubkey } });
if (!target) {
res.status(404).json({ error: 'User not found' });
return;
}
const existing = await prisma.user.findFirst({
where: {
username: { equals: normalized },
NOT: { pubkey },
},
});
if (existing) {
res.status(409).json({ error: 'Username is already taken' });
return;
}
const user = await prisma.user.update({
where: { pubkey },
data: { username: normalized },
});
res.json(user);
} catch (err) {
console.error('Admin update user username error:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
);
export default router;

View File

@@ -23,6 +23,7 @@ import {
Check,
} from "lucide-react";
import { MediaPickerModal } from "@/components/admin/MediaPickerModal";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
interface Meetup {
id: string;
@@ -73,12 +74,14 @@ type EditableStatus = (typeof EDITABLE_STATUS_OPTIONS)[number];
// Display statuses (includes computed Upcoming/Past from PUBLISHED + date)
type DisplayStatus = "DRAFT" | "UPCOMING" | "PAST" | "CANCELLED";
function getDisplayStatus(meetup: { status: string; date: string }): DisplayStatus {
function getDisplayStatus(meetup: { status: string; date: string; time: string }): DisplayStatus {
if (meetup.status === "CANCELLED") return "CANCELLED";
if (meetup.status === "DRAFT") return "DRAFT";
// PUBLISHED (or legacy UPCOMING/PAST values) → derive from date
if (!meetup.date) return "DRAFT";
return new Date(meetup.date) > new Date() ? "UPCOMING" : "PAST";
// PUBLISHED (or legacy UPCOMING/PAST values) → derive from Brussels-local start (date + time)
if (!meetup.date?.trim()) return "DRAFT";
const start = getMeetupStartUtc(meetup.date, meetup.time || "00:00");
if (Number.isNaN(start.getTime())) return "DRAFT";
return start > new Date() ? "UPCOMING" : "PAST";
}
const STATUS_LABELS: Record<string, string> = {
@@ -158,7 +161,7 @@ function StatusDropdown({
meetup,
onChange,
}: {
meetup: { status: string; date: string };
meetup: { status: string; date: string; time: string };
onChange: (v: string) => void;
}) {
const [open, setOpen] = useState(false);

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { formatDate } from "@/lib/utils";
import { Calendar, FileText, Tag, User, Plus, Download, FolderOpen } from "lucide-react";
import Link from "next/link";
@@ -54,9 +55,11 @@ export default function OverviewPage() {
const shortPubkey = `${user.pubkey.slice(0, 8)}...${user.pubkey.slice(-8)}`;
const upcomingMeetup = meetups.find(
(m) => new Date(m.date) > new Date()
);
const upcomingMeetup = meetups.find((m) => {
const start = getMeetupStartUtc(m.date, m.time || "00:00");
if (Number.isNaN(start.getTime())) return false;
return start > new Date();
});
if (loading) {
return (

View File

@@ -1,22 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { nip19 } from "nostr-tools";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import { cn, formatDate } from "@/lib/utils";
import { fetchNostrProfile, type NostrProfile } from "@/lib/nostr";
import { ShieldCheck, ShieldOff, UserPlus } from "lucide-react";
function hexToNpub(hex: string): string {
try {
return nip19.npubEncode(hex);
} catch {
return "";
}
}
function shortenNpub(npub: string): string {
if (npub.length <= 26) return npub;
return `${npub.slice(0, 14)}...${npub.slice(-10)}`;
}
function profileInitials(profile: NostrProfile | undefined, npub: string): string {
const n = profile?.name || profile?.displayName;
if (n?.trim()) return n.trim().slice(0, 2).toUpperCase();
if (npub.length >= 8) return npub.slice(5, 7).toUpperCase();
return "?";
}
export default function UsersPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [promotePubkey, setPromotePubkey] = useState("");
const [promoting, setPromoting] = useState(false);
const [hostname, setHostname] = useState("");
const [usernameDrafts, setUsernameDrafts] = useState<Record<string, string>>({});
const [savingPubkey, setSavingPubkey] = useState<string | null>(null);
const [nostrByPubkey, setNostrByPubkey] = useState<Record<string, NostrProfile>>({});
const [nostrLoading, setNostrLoading] = useState(false);
const [copiedPubkey, setCopiedPubkey] = useState<string | null>(null);
const loadUsers = async () => {
try {
const data = await api.getUsers();
setUsers(data);
setUsernameDrafts(
Object.fromEntries(
data.map((u: { pubkey: string; username?: string | null }) => [u.pubkey, u.username ?? ""])
)
);
} catch (err: any) {
setError(err.message);
} finally {
@@ -28,6 +61,48 @@ export default function UsersPage() {
loadUsers();
}, []);
useEffect(() => {
setHostname(typeof window !== "undefined" ? window.location.hostname : "");
}, []);
useEffect(() => {
if (users.length === 0) {
setNostrByPubkey({});
setNostrLoading(false);
return;
}
let cancelled = false;
setNostrLoading(true);
setNostrByPubkey({});
(async () => {
const entries = await Promise.all(
users.map(async (u: { pubkey: string }) => {
const profile = await fetchNostrProfile(u.pubkey);
return [u.pubkey, profile] as const;
})
);
if (!cancelled) {
setNostrByPubkey(Object.fromEntries(entries));
setNostrLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [users]);
const handleCopyNpub = async (pubkey: string) => {
const full = hexToNpub(pubkey);
if (!full) return;
try {
await navigator.clipboard.writeText(full);
setCopiedPubkey(pubkey);
window.setTimeout(() => setCopiedPubkey((p) => (p === pubkey ? null : p)), 2000);
} catch {
// ignore
}
};
const handlePromote = async () => {
if (!promotePubkey.trim()) return;
setPromoting(true);
@@ -64,6 +139,26 @@ export default function UsersPage() {
}
};
const handleSaveUsername = async (pubkey: string, currentUsername: string | null | undefined) => {
const trimmed = (usernameDrafts[pubkey] ?? "").trim().toLowerCase();
if (!trimmed) return;
setSavingPubkey(pubkey);
setError("");
try {
await api.updateUserUsername(pubkey, trimmed);
await loadUsers();
} catch (err: any) {
setError(err.message);
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: currentUsername ?? "" }));
} finally {
setSavingPubkey(null);
}
};
const handleCancelUsername = (pubkey: string, stored: string | null | undefined) => {
setUsernameDrafts((prev) => ({ ...prev, [pubkey]: stored ?? "" }));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
@@ -102,16 +197,109 @@ export default function UsersPage() {
{users.length === 0 ? (
<p className="text-on-surface/50 text-sm">No users found.</p>
) : (
users.map((user) => (
users.map((user) => {
const draft = usernameDrafts[user.pubkey] ?? "";
const stored = user.username ?? "";
const usernameDirty = draft.trim().toLowerCase() !== stored.toLowerCase();
const profile = nostrByPubkey[user.pubkey];
const fullNpub = hexToNpub(user.pubkey);
const nostrDisplay = profile?.name || profile?.displayName;
return (
<div
key={user.pubkey || user.id}
className="bg-surface-container-low rounded-xl p-6 flex items-center justify-between"
className="bg-surface-container-low rounded-xl p-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div>
<p className="text-on-surface font-mono text-sm">
{user.pubkey?.slice(0, 12)}...{user.pubkey?.slice(-8)}
</p>
<div className="flex items-center gap-3 mt-2">
<div className="flex-1 min-w-0 flex gap-4 items-start">
<div className="shrink-0 w-14 h-14 rounded-full bg-surface-container-high flex items-center justify-center overflow-hidden text-on-surface">
{nostrLoading ? (
<span className="text-on-surface/40 text-xs"></span>
) : profile?.picture ? (
<Image
src={profile.picture}
alt={nostrDisplay ? `Avatar: ${nostrDisplay}` : "Nostr profile picture"}
width={56}
height={56}
className="object-cover w-full h-full"
unoptimized
/>
) : (
<span className="font-semibold text-sm" aria-hidden>
{profileInitials(profile, fullNpub)}
</span>
)}
</div>
<div className="flex-1 min-w-0 space-y-3">
<div>
<p className="text-on-surface font-semibold text-base truncate">
{nostrLoading ? (
<span className="text-on-surface/40 font-normal"></span>
) : nostrDisplay ? (
nostrDisplay
) : (
<span className="text-on-surface/50 font-normal">No Nostr name</span>
)}
</p>
<button
type="button"
onClick={() => handleCopyNpub(user.pubkey)}
className="mt-1 text-left font-mono text-sm text-on-surface/80 hover:text-primary transition-colors cursor-pointer break-all w-full"
title={fullNpub || "Copy npub"}
>
{copiedPubkey === user.pubkey
? "Copied!"
: fullNpub
? shortenNpub(fullNpub)
: `${user.pubkey?.slice(0, 12)}...${user.pubkey?.slice(-8)}`}
</button>
</div>
<div>
<p className="text-xs font-semibold text-on-surface/50 mb-1 uppercase tracking-wide">
NIP-05 username
</p>
<p className="text-on-surface-variant text-xs mb-2">
Reserved names from the site blocklist can be assigned here (users cannot claim them on the dashboard).
</p>
<div className="flex flex-wrap items-center gap-2">
<input
value={draft}
onChange={(e) =>
setUsernameDrafts((prev) => ({ ...prev, [user.pubkey]: e.target.value }))
}
disabled={savingPubkey === user.pubkey}
placeholder="local-part"
className="bg-surface-container-highest text-on-surface rounded-lg px-3 py-2 text-sm font-mono min-w-[8rem] max-w-full flex-1 focus:outline-none focus:ring-1 focus:ring-primary/40 disabled:opacity-50"
/>
<span className="text-on-surface/50 text-sm font-mono shrink-0">
@{hostname || "…"}
</span>
<button
type="button"
onClick={() => handleSaveUsername(user.pubkey, user.username)}
disabled={
savingPubkey === user.pubkey ||
!draft.trim() ||
!usernameDirty
}
className="px-3 py-2 rounded-lg bg-gradient-to-r from-primary to-primary-container text-on-primary font-semibold text-sm hover:opacity-90 transition-opacity disabled:opacity-50 whitespace-nowrap"
>
{savingPubkey === user.pubkey ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => handleCancelUsername(user.pubkey, user.username)}
disabled={savingPubkey === user.pubkey || !usernameDirty}
className="px-3 py-2 rounded-lg bg-surface-container-highest text-on-surface/70 text-sm hover:text-on-surface transition-colors disabled:opacity-50 whitespace-nowrap"
>
Cancel
</button>
</div>
{draft.trim() && (
<p className="text-on-surface-variant text-xs font-mono mt-2">
{draft.trim().toLowerCase()}@{hostname || "…"}
</p>
)}
</div>
<div className="flex items-center gap-3 flex-wrap">
<span
className={cn(
"rounded-full px-3 py-1 text-xs font-bold",
@@ -131,6 +319,7 @@ export default function UsersPage() {
)}
</div>
</div>
</div>
{user.role !== "ADMIN" && (
<div className="flex items-center gap-2">
{user.role !== "MODERATOR" && (
@@ -154,7 +343,8 @@ export default function UsersPage() {
</div>
)}
</div>
))
);
})
)}
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { MapPin, Clock, ArrowRight } from "lucide-react";
import { api } from "@/lib/api";
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
@@ -110,8 +111,18 @@ export default function EventsPage() {
}, []);
const now = new Date();
const upcoming = meetups.filter((m) => new Date(m.date) >= now);
const past = meetups.filter((m) => new Date(m.date) < now).reverse();
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();
return (
<>

View File

@@ -3,16 +3,45 @@ import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
/** Walk up from cwd until we find backend + frontend package.json (monorepo root). */
function findMonorepoRoot(start: string): string {
let dir = path.resolve(start);
for (let i = 0; i < 8; i++) {
if (
fs.existsSync(path.join(dir, 'backend', 'package.json')) &&
fs.existsSync(path.join(dir, 'frontend', 'package.json'))
) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.resolve(start, '..');
}
/**
* Resolve relative MEDIA_STORAGE_PATH against monorepo root — matches
* `backend/src/api/media.ts` (REPO_ROOT + relative path).
*/
function resolveMediaStoragePathFromEnv(envPath: string): string {
if (path.isAbsolute(envPath)) {
return envPath;
}
return path.resolve(findMonorepoRoot(process.cwd()), envPath);
}
/** Resolve at request time so systemd / production env is visible (avoid build-time inlining). */
function getMediaStorageRoot(): string {
const envPath = process.env['MEDIA_STORAGE_PATH'];
if (envPath) {
return path.resolve(envPath);
return resolveMediaStoragePathFromEnv(envPath);
}
const cwd = process.cwd();
const root = findMonorepoRoot(process.cwd());
// Prefer repo-root `storage/media` first — matches backend default in `backend/src/api/media.ts`.
const candidates = [
path.resolve(cwd, '../backend/storage/media'),
path.resolve(cwd, '../storage/media'),
path.join(root, 'storage', 'media'),
path.join(root, 'backend', 'storage', 'media'),
];
for (const dir of candidates) {
if (fs.existsSync(dir)) {

View File

@@ -10,26 +10,32 @@ const LINKS = [
export function Footer() {
return (
<footer className="w-full py-12 bg-surface-container-lowest">
<div className="flex flex-col items-center justify-center space-y-6 w-full px-8 text-center">
<Link href="/" className="text-lg font-black text-primary-container">
<footer className="w-full bg-surface-container-lowest pt-8 sm:pt-12 pb-[max(2rem,env(safe-area-inset-bottom))] sm:pb-[max(3rem,env(safe-area-inset-bottom))]">
<div className="flex flex-col items-center justify-center gap-6 w-full px-4 sm:px-8 text-center">
<Link
href="/"
className="text-base sm:text-lg font-black text-primary-container px-2 leading-tight"
>
Belgian Bitcoin Embassy
</Link>
<nav aria-label="Footer navigation" className="flex space-x-12">
<nav
aria-label="Footer navigation"
className="flex flex-wrap justify-center gap-x-4 gap-y-2 sm:gap-x-8 sm:gap-y-3 md:gap-x-12"
>
{LINKS.map((link) => (
<Link
key={link.label}
href={link.href}
className="text-white opacity-50 hover:opacity-100 transition-opacity text-sm tracking-widest uppercase"
className="text-white opacity-50 hover:opacity-100 transition-opacity text-xs sm:text-sm tracking-widest uppercase inline-flex min-h-[44px] items-center px-1.5"
>
{link.label}
</Link>
))}
</nav>
<p className="text-white opacity-50 text-sm tracking-widest uppercase">
&copy; Belgian Bitcoin Embassy. No counterparty risk.
<p className="text-white opacity-50 text-xs sm:text-sm tracking-widest uppercase max-w-[min(100%,22rem)] sm:max-w-md leading-relaxed text-balance">
&copy; Belgian Bitcoin Embassy.
</p>
</div>
</footer>

View File

@@ -11,7 +11,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(apiUrl(path), { ...options, headers });
if (!res.ok) {
const error = await res.json().catch(() => ({ message: "Request failed" }));
throw new Error(error.message || `HTTP ${res.status}`);
throw new Error(error.message || error.error || `HTTP ${res.status}`);
}
return res.json();
}
@@ -88,6 +88,11 @@ export const api = {
request<any>("/users/promote", { method: "POST", body: JSON.stringify({ pubkey }) }),
demoteUser: (pubkey: string) =>
request<any>("/users/demote", { method: "POST", body: JSON.stringify({ pubkey }) }),
updateUserUsername: (pubkey: string, username: string) =>
request<any>(`/users/${encodeURIComponent(pubkey)}`, {
method: "PATCH",
body: JSON.stringify({ username }),
}),
// Categories
getCategories: () => request<any[]>("/categories"),

View File

@@ -0,0 +1,53 @@
/**
* Event start times in Brussels local wall time, converted to UTC the same way as
* backend/src/api/calendar.ts (parseEventDates). Keeps admin/public UI aligned with ICS.
*/
// Parse "HH:MM", "H:MM am/pm", "Hpm" etc.
function parseLocalTime(t: string): { h: number; m: number } {
const clean = t.trim();
const m24 = clean.match(/^(\d{1,2}):(\d{2})$/);
if (m24) return { h: parseInt(m24[1], 10), m: parseInt(m24[2], 10) };
const mAp = clean.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
if (mAp) {
let h = parseInt(mAp[1], 10);
const m = mAp[2] ? parseInt(mAp[2], 10) : 0;
if (mAp[3].toLowerCase() === "pm" && h !== 12) h += 12;
if (mAp[3].toLowerCase() === "am" && h === 12) h = 0;
return { h, m };
}
return { h: 18, m: 0 };
}
// Brussels is UTC+1 (CET) / UTC+2 (CEST). Same as calendar.ts.
const BRUSSELS_OFFSET_HOURS = 1;
/** Extract YYYY-MM-DD from stored date (ISO date-only or full ISO datetime). */
export function normalizeMeetupDateKey(dateStr: string): string | null {
const s = dateStr?.trim();
if (!s) return null;
const dayPart = s.includes("T") ? s.split("T")[0]! : s.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(dayPart)) return null;
return dayPart;
}
/**
* Returns event start instant in UTC, or Invalid Date if date/time cannot be parsed.
*/
export function getMeetupStartUtc(dateStr: string, timeStr: string): Date {
const key = normalizeMeetupDateKey(dateStr);
if (!key) return new Date(NaN);
const parts = key.split("-").map(Number);
const year = parts[0];
const month = parts[1];
const day = parts[2];
if (!year || !month || !day) return new Date(NaN);
const t = timeStr?.trim() ? timeStr : "00:00";
const timeParts = t.split(/\s*[-]\s*/);
const { h: startH, m: startM } = parseLocalTime(timeParts[0] ?? "");
const utcStartH = startH - BRUSSELS_OFFSET_HOURS;
return new Date(Date.UTC(year, month - 1, day, utcStartH, startM, 0));
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB