Revert "Add missing admin action files and navigation links"

This reverts commit 41be6835bf.
This commit is contained in:
Simo committed 2026-07-11 20:37:56 +02:00
1 parent 4a1e1115b3
commit 4d515bc400
338 files changed
+5949 -28144

No files matched your search

+1 -9
View File
@@ -9,15 +9,7 @@ import { usePathname } from "next/navigation";
* exactly; every other entry also matches its sub-routes (e.g. /admin/radio is
* active on /admin/radio/history) so the current section stays lit.
*/
export function AdminNavLink({
href,
icon,
children,
}: {
href: string;
icon?: ReactNode;
children: ReactNode;
}) {
export function AdminNavLink({ href, icon, children }: { href: string; icon?: ReactNode; children: ReactNode }) {
const pathname = usePathname() ?? "";
const active =
href === "/admin" ? pathname === "/admin" : pathname === href || pathname.startsWith(`${href}/`);
+5 -26
View File
@@ -22,20 +22,11 @@ export function ArticleForm({
<input
name="image"
value={image}
onChange={(e) => {
setImage(e.target.value);
setImageError(false);
}}
onChange={(e) => { setImage(e.target.value); setImageError(false); }}
placeholder="Image URL"
style={{ flex: 1 }}
/>
<MediaPicker
onSelect={(url) => {
setImage(url);
setImageError(false);
}}
current={image}
/>
<MediaPicker onSelect={(url) => { setImage(url); setImageError(false); }} current={image} />
</div>
{image && !imageError ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -47,23 +38,11 @@ export function ArticleForm({
/>
) : null}
{imageError ? (
<p className="muted" style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}>
Image not found — enter a valid URL or browse.
</p>
<p className="muted" style={{ margin: "0.3rem 0 0", fontSize: "0.78rem" }}>Image not found — enter a valid URL or browse.</p>
) : null}
</div>
<textarea
name="shortStory"
defaultValue={defaultValues?.shortStory ?? ""}
placeholder="Short story (summary)"
rows={2}
/>
<RichText
name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""}
placeholder="Full story"
rows={10}
/>
<textarea name="shortStory" defaultValue={defaultValues?.shortStory ?? ""} placeholder="Short story (summary)" rows={2} />
<RichText name="fullStory" defaultValue={defaultValues?.fullStory ?? ""} placeholder="Full story" rows={10} />
<button type="submit" className="btn btn-primary">
{defaultValues ? "Save" : "Create"}
</button>
+13 -28
View File
@@ -31,9 +31,7 @@ export function StatusCard({
return (
<div className="bg-[var(--color-surface)] border border-[var(--border-subtle)] rounded-2xl p-5 shadow-card hover:shadow-card-hover transition-shadow duration-200">
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--color-text-muted)]">
{label}
</span>
<span className="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--color-text-muted)]">{label}</span>
{icon ? (
<span className="w-8 h-8 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/15 to-[var(--color-primary)]/5 grid place-items-center text-[var(--color-primary)] flex-none">
{icon}
@@ -41,9 +39,7 @@ export function StatusCard({
) : null}
</div>
<div className="text-2xl font-extrabold text-[var(--color-text)] leading-tight">{value}</div>
{hint ? (
<div className="text-[0.7rem] text-[var(--color-text-muted)] mt-1.5 font-medium">{hint}</div>
) : null}
{hint ? <div className="text-[0.7rem] text-[var(--color-text-muted)] mt-1.5 font-medium">{hint}</div> : null}
</div>
);
}
@@ -59,27 +55,19 @@ export function DiagnosticRow({
state: Exclude<State, "neutral">;
}) {
const text = state === "ok" ? "OK" : state === "warn" ? "WARN" : "ERROR";
const dotColor =
state === "ok"
? "bg-green-600 shadow-[0_0_0_3px_rgba(22,163,74,0.22)]"
: state === "warn"
? "bg-[var(--color-primary)] shadow-[0_0_0_3px_rgba(238,180,37,0.22)]"
: "bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
const badgeColor =
state === "ok"
? "bg-green-600/20"
: state === "danger"
? "bg-red-500/22"
: "bg-[var(--color-primary)]/18";
const dotColor = state === "ok" ? "bg-green-600 shadow-[0_0_0_3px_rgba(22,163,74,0.22)]" :
state === "warn" ? "bg-[var(--color-primary)] shadow-[0_0_0_3px_rgba(238,180,37,0.22)]" :
"bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.22)]";
const badgeColor = state === "ok" ? "bg-green-600/20" :
state === "danger" ? "bg-red-500/22" :
"bg-[var(--color-primary)]/18";
return (
<div className="flex items-center gap-2.5 py-2 px-0.5 border-t border-[var(--border-subtle)] first:border-t-0">
<span className={`flex-none w-[9px] h-[9px] rounded-full ${dotColor}`} aria-hidden />
<span className="font-bold text-sm">{label}</span>
<span className="flex-1 text-[0.82rem] text-[var(--color-text-muted)]">{detail}</span>
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--color-text)]`}
>
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${badgeColor} text-[var(--color-text)]`}>
{text}
</span>
</div>
@@ -107,7 +95,7 @@ export function OnlineUsersWidget({
const formatLastSeen = (date: Date) => {
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
@@ -122,7 +110,7 @@ export function OnlineUsersWidget({
{isLoading ? "..." : users.length} users
</span>
</div>
<div className="max-h-80 overflow-y-auto flex flex-col gap-3">
{users.length === 0 ? (
<div className="py-8 text-center bg-[var(--color-text-muted)]/5 rounded-lg">
@@ -148,13 +136,10 @@ export function OnlineUsersWidget({
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-[var(--color-text)] mb-1 truncate">
<a
href={`/u/${user.username}`}
className="text-inherit no-underline hover:text-[var(--color-primary)] hover:underline"
>
<a href={`/u/${user.username}`} className="text-inherit no-underline hover:text-[var(--color-primary)] hover:underline">
{user.username}
</a>
</div>
+21 -45
View File
@@ -25,36 +25,30 @@ export function AdminMediaGrid() {
const res = await fetch("/api/media");
const data = await res.json();
setFiles(data.files ?? []);
} catch {
/* ignore */
}
} catch { /* ignore */ }
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
useEffect(() => { load(); }, [load]);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploading(true);
setProgress({ current: 0, total: files.length });
const fileArray = Array.from(files);
await Promise.all(
fileArray.map(async (file) => {
const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
} catch (err) {
console.error(err);
}
setProgress((prev) => ({ ...prev, current: prev.current + 1 }));
}),
);
await Promise.all(fileArray.map(async (file) => {
const fd = new FormData();
fd.set("file", file);
try {
await uploadMedia(fd);
} catch (err) {
console.error(err);
}
setProgress(prev => ({ ...prev, current: prev.current + 1 }));
}));
await load();
setUploading(false);
@@ -69,19 +63,10 @@ export function AdminMediaGrid() {
return (
<div className="admin-card mb-4">
<div className="flex items-center justify-between mb-3">
<strong>
{files.length} image{files.length !== 1 ? "s" : ""}
</strong>
<strong>{files.length} image{files.length !== 1 ? "s" : ""}</strong>
<label className="btn btn-primary cursor-pointer text-sm">
{uploading ? "Uploading…" : "Upload image"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
multiple
className="hidden"
/>
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} multiple className="hidden" />
</label>
</div>
@@ -89,13 +74,11 @@ export function AdminMediaGrid() {
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span>Uploading images...</span>
<span>
{progress.current} / {progress.total}
</span>
<span>{progress.current} / {progress.total}</span>
</div>
<div className="h-2 w-full bg-[var(--color-primary)]/10 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-hover)] transition-all duration-300"
<div
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-hover)] transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
@@ -109,21 +92,14 @@ export function AdminMediaGrid() {
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-3">
{files.map((f) => (
<div
key={f.name}
className="border border-[var(--color-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--color-background)]"
>
<div key={f.name} className="border border-[var(--color-text-muted)]/14 rounded-[10px] overflow-hidden bg-[var(--color-background)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={validImageUrl(f.url)} alt={f.name} className="w-full h-[120px] object-cover block" />
<div className="p-2">
<p className="text-xs text-[var(--color-text-muted)] m-0 mb-1 overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</p>
<button
type="button"
onClick={() => copyUrl(f.url)}
className="btn btn-outline text-xs p-1 w-full"
>
<button type="button" onClick={() => copyUrl(f.url)} className="btn btn-outline text-xs p-1 w-full">
Copy URL
</button>
</div>
+60 -84
View File
@@ -12,7 +12,13 @@ function validImageUrl(url: string): string {
return url;
}
export function MediaPicker({ onSelect, current }: { onSelect: (url: string) => void; current?: string }) {
export function MediaPicker({
onSelect,
current,
}: {
onSelect: (url: string) => void;
current?: string;
}) {
const [open, setOpen] = useState(false);
const [files, setFiles] = useState<MediaFile[]>([]);
const [loading, setLoading] = useState(false);
@@ -58,102 +64,72 @@ export function MediaPicker({ onSelect, current }: { onSelect: (url: string) =>
return (
<>
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center" }}>
<button
type="button"
onClick={() => setOpen(true)}
className="btn btn-outline"
style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem" }}
>
<button type="button" onClick={() => setOpen(true)} className="btn btn-outline" style={{ fontSize: "0.82rem", padding: "0.3rem 0.7rem" }}>
Browse
</button>
{current && (
<span
style={{
fontSize: "0.78rem",
color: "var(--color-text-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 200,
}}
>
<span style={{ fontSize: "0.78rem", color: "var(--color-text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 200 }}>
{current.split("/").pop()}
</span>
)}
</div>
{open && (
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setOpen(false)}
>
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setOpen(false)}
onClick={(e) => e.stopPropagation()}
className="bg-[var(--color-surface)] text-[var(--color-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
>
<div
onClick={(e) => e.stopPropagation()}
className="bg-[var(--color-surface)] text-[var(--color-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-text-muted)]/14">
<strong className="text-base">Media Library</strong>
<div className="flex gap-2 items-center">
<label className="btn btn-primary text-sm cursor-pointer">
{uploading ? "Uploading…" : "Upload"}
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleUpload}
className="hidden"
multiple
/>
</label>
<button type="button" onClick={() => setOpen(false)} className="btn text-sm">
Close
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{loading ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">Loading…</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => {
onSelect(f.url);
setOpen(false);
}}
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--color-background)] p-0 flex flex-col transition-all duration-150"
style={{ borderColor: f.url === current ? "var(--color-primary)" : "transparent" }}
onMouseEnter={(e) => {
if (f.url !== current)
e.currentTarget.style.borderColor =
"color-mix(in srgb, var(--color-primary) 40%, transparent)";
}}
onMouseLeave={(e) => {
if (f.url !== current) e.currentTarget.style.borderColor = "transparent";
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-text-muted)]/14">
<strong className="text-base">Media Library</strong>
<div className="flex gap-2 items-center">
<label className="btn btn-primary text-sm cursor-pointer">
{uploading ? "Uploading…" : "Upload"}
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/gif,image/webp" onChange={handleUpload} className="hidden" multiple />
</label>
<button type="button" onClick={() => setOpen(false)} className="btn text-sm">
Close
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{loading ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">Loading…</p>
) : files.length === 0 ? (
<p className="text-xs text-[var(--color-text-muted)] text-center py-8">
No images yet. Upload one above.
</p>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2.5">
{files.map((f) => (
<button
type="button"
key={f.name}
onClick={() => { onSelect(f.url); setOpen(false); }}
className="border-2 border-transparent rounded-xl overflow-hidden cursor-pointer bg-[var(--color-background)] p-0 flex flex-col transition-all duration-150"
style={{ borderColor: f.url === current ? "var(--color-primary)" : "transparent" }}
onMouseEnter={(e) => { if (f.url !== current) e.currentTarget.style.borderColor = "color-mix(in srgb, var(--color-primary) 40%, transparent)"; }}
onMouseLeave={(e) => { if (f.url !== current) e.currentTarget.style.borderColor = "transparent"; }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={validImageUrl(f.url)}
alt={f.name}
className="w-full h-[100px] object-cover block"
/>
<span className="text-xs py-1 text-center overflow-hidden text-ellipsis whitespace-nowrap">
{f.name}
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</>
);
+8 -3
View File
@@ -5,7 +5,8 @@ import { useEffect, useId, useRef, useState } from "react";
// TinyMCE community build, loaded from the public jsDelivr CDN. `no-api-key`
// is the documented sentinel for the free self-hosted/CDN build and silences
// the "register for an API key" notification.
const TINYMCE_SRC = "https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
const TINYMCE_SRC =
"https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
type TinyMCEGlobal = {
init: (opts: Record<string, unknown>) => Promise<unknown>;
@@ -81,7 +82,8 @@ export function RichText({
menubar: false,
height: Math.max(rows * 22, 240),
plugins: "link lists code autolink",
toolbar: "undo redo | bold italic underline | bullist numlist | link | removeformat | code",
toolbar:
"undo redo | bold italic underline | bullist numlist | link | removeformat | code",
branding: false,
promotion: false,
license_key: "gpl",
@@ -89,7 +91,10 @@ export function RichText({
content_style: "body{font-family:inherit;font-size:14px}",
// Mirror typed HTML back into the underlying textarea so the form
// submit (server action) always sees the current value.
setup: (editor: { on: (ev: string, cb: () => void) => void; getContent: () => string }) => {
setup: (editor: {
on: (ev: string, cb: () => void) => void;
getContent: () => string;
}) => {
const sync = () => {
if (textareaRef.current) textareaRef.current.value = editor.getContent();
};
+4 -5
View File
@@ -45,10 +45,7 @@ export function HomeLoginForm() {
}
return (
<form
onSubmit={onSubmit}
className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
>
<form onSubmit={onSubmit} className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800">
<fieldset className="flex w-full flex-col gap-1">
<label className="block font-semibold text-gray-700 dark:text-gray-200">Username</label>
<input
@@ -90,7 +87,9 @@ export function HomeLoginForm() {
/>
</fieldset>
) : null}
{error ? <p className="m-0 text-center text-sm text-red-600 dark:text-red-400">{error}</p> : null}
{error ? (
<p className="m-0 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
) : null}
<button
type="submit"
disabled={pending}
+7 -5
View File
@@ -59,9 +59,12 @@ export function LanguageSwitcher() {
</svg>
</button>
{open && (
<div className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[160px] rounded-xl z-50 py-2">
{LOCALES.map((l) => (
{open && (
<div
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[160px] rounded-xl z-50 py-2"
>
{LOCALES.map((l) => (
<button
key={l.code}
type="button"
@@ -77,8 +80,7 @@ export function LanguageSwitcher() {
width: "calc(100% - 8px)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"color-mix(in srgb, var(--color-primary) 10%, transparent)";
e.currentTarget.style.backgroundColor = "color-mix(in srgb, var(--color-primary) 10%, transparent)";
e.currentTarget.style.color = "var(--color-primary)";
}}
onMouseLeave={(e) => {
+2 -10
View File
@@ -23,15 +23,7 @@ export function MobileNav({ children, menuLabel = "Open menu", closeLabel = "Clo
aria-controls="mobile-menu"
aria-label={open ? closeLabel : menuLabel}
>
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{open ? (
<>
<line x1="18" y1="6" x2="6" y2="18" />
@@ -77,4 +69,4 @@ export function MobileNav({ children, menuLabel = "Open menu", closeLabel = "Clo
</div>
</>
);
}
}
+3 -16
View File
@@ -35,30 +35,17 @@ export function NavDropdown({ label, icon, children }: NavDropdownProps) {
<button
type="button"
onClick={() => setOpen(!open)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setOpen(!open);
}}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") setOpen(!open); }}
className="nav-item cursor-pointer"
aria-expanded={open}
aria-haspopup="true"
>
{icon ? <Image src={icon} alt="" width={20} height={20} className="mr-1.5" unoptimized /> : null}
{label}
<svg
className={`ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
<svg className={`ml-auto md:ml-1 w-3 h-3 transition-transform duration-200 ${open ? "rotate-180" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{open && (
<div
className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2"
role="menu"
onClick={() => setOpen(false)}
>
<div className="dropdown-menu md:absolute left-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-2" role="menu" onClick={() => setOpen(false)}>
{children}
</div>
)}
+15 -47
View File
@@ -26,10 +26,7 @@ export function NavbarColorPicker() {
if (typeof window === "undefined") return 0;
const saved = localStorage.getItem("navbarColor");
if (!saved) return 0;
return Math.max(
0,
COLORS.findIndex((c) => c.value === saved),
);
return Math.max(0, COLORS.findIndex((c) => c.value === saved));
});
const [mounted, setMounted] = useState(false);
@@ -42,7 +39,7 @@ export function NavbarColorPicker() {
try {
localStorage.setItem("navbarColor", c.value);
localStorage.setItem("navbarTextColor", c.text);
} catch {}
} catch { }
setActive(index);
setOpen(false);
}
@@ -68,15 +65,7 @@ export function NavbarColorPicker() {
aria-label="Navbar kleur"
title="Navbar kleur"
>
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="13.5" cy="6.5" r="0.5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r="0.5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r="0.5" fill="currentColor" />
@@ -85,15 +74,13 @@ export function NavbarColorPicker() {
</svg>
</button>
{mounted && open && (
<div className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-3 px-2">
<div
className="text-xs font-semibold uppercase tracking-wider mb-2 px-2"
style={{ color: "var(--color-text-muted)" }}
>
Navbar kleur
</div>
<div className="grid grid-cols-5 gap-1.5">
{mounted && open && (
<div
className="dropdown-menu absolute right-0 top-full mt-0.5 min-w-[200px] rounded-xl z-50 py-3 px-2"
>
<div className="text-xs font-semibold uppercase tracking-wider mb-2 px-2" style={{ color: "var(--color-text-muted)" }}>Navbar kleur</div>
<div className="grid grid-cols-5 gap-1.5">
{COLORS.map((c, i) => (
<button
key={c.name}
@@ -102,38 +89,19 @@ export function NavbarColorPicker() {
className="flex flex-col items-center gap-1 p-1.5 rounded-lg transition-all duration-100"
title={c.name}
style={{
background:
i === active
? "color-mix(in srgb, var(--color-primary) 12%, transparent)"
: "transparent",
background: i === active ? "color-mix(in srgb, var(--color-primary) 12%, transparent)" : "transparent",
border: "none",
cursor: "pointer",
}}
onMouseEnter={(e) => {
if (i !== active)
e.currentTarget.style.backgroundColor =
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)";
if (i !== active) e.currentTarget.style.backgroundColor = "color-mix(in srgb, var(--color-text-muted) 8%, transparent)";
}}
onMouseLeave={(e) => {
if (i !== active) e.currentTarget.style.backgroundColor = "transparent";
}}
>
<span
className="block w-6 h-6 rounded-full border-2 shrink-0"
style={{
backgroundColor: c.value,
borderColor:
i === active
? "var(--color-primary)"
: "color-mix(in srgb, var(--color-text-muted) 20%, transparent)",
}}
/>
<span
className="text-[10px] font-medium leading-tight text-center"
style={{ color: "var(--color-text-muted)" }}
>
{c.name}
</span>
<span className="block w-6 h-6 rounded-full border-2 shrink-0" style={{ backgroundColor: c.value, borderColor: i === active ? "var(--color-primary)" : "color-mix(in srgb, var(--color-text-muted) 20%, transparent)" }} />
<span className="text-[10px] font-medium leading-tight text-center" style={{ color: "var(--color-text-muted)" }}>{c.name}</span>
</button>
))}
</div>
@@ -141,4 +109,4 @@ export function NavbarColorPicker() {
)}
</div>
);
}
}
+22 -94
View File
@@ -16,125 +16,62 @@ export async function Navigation() {
return (
<nav className="sticky top-0 z-40 shadow-sm" style={{ backgroundColor: "var(--color-navbar)" }}>
<div className="relative mx-auto flex min-h-[56px] max-w-7xl items-center px-4 md:px-6 gap-x-1">
<div
className="absolute bottom-0 left-0 right-0 h-[3px]"
style={{ backgroundColor: "var(--color-primary, #eeb425)" }}
/>
<div className="absolute bottom-0 left-0 right-0 h-[3px]" style={{ backgroundColor: "var(--color-primary, #eeb425)" }} />
<div className="flex items-center gap-x-1 md:gap-x-1 flex-1 md:flex-none">
<Link href="/" className="nav-item shrink-0">
<Image
src="/assets/images/icons/navigation/me.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<Image src="/assets/images/icons/navigation/me.png" alt="" width={20} height={20} className="mr-1.5" />
<span>{t("home")}</span>
</Link>
</div>
<MobileNav menuLabel={t("openMenu") || "Open menu"} closeLabel={t("closeMenu") || "Close menu"}>
<Link href="/" className="nav-item md:hidden" role="menuitem">
<Image
src="/assets/images/icons/navigation/me.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<Image src="/assets/images/icons/navigation/me.png" alt="" width={20} height={20} className="mr-1.5" />
{t("home")}
</Link>
<NavDropdown label={t("community")} icon="/assets/images/icons/navigation/community.png">
<Link href="/news" className="dropdown-item" role="menuitem">
{t("articles")}
</Link>
<Link href="/photos" className="dropdown-item" role="menuitem">
{t("photos")}
</Link>
<Link href="/staff" className="dropdown-item" role="menuitem">
{t("staff")}
</Link>
<Link href="/rankings" className="dropdown-item" role="menuitem">
{t("rankings")}
</Link>
<Link href="/guilds" className="dropdown-item" role="menuitem">
{t("guilds")}
</Link>
<Link href="/news" className="dropdown-item" role="menuitem">{t("articles")}</Link>
<Link href="/photos" className="dropdown-item" role="menuitem">{t("photos")}</Link>
<Link href="/staff" className="dropdown-item" role="menuitem">{t("staff")}</Link>
<Link href="/rankings" className="dropdown-item" role="menuitem">{t("rankings")}</Link>
<Link href="/guilds" className="dropdown-item" role="menuitem">{t("guilds")}</Link>
</NavDropdown>
<NavDropdown label={t("shop")} icon="/assets/images/icons/navigation/shop.png">
<Link href="/shop" className="dropdown-item" role="menuitem">
{t("shop")}
</Link>
<Link href="/marketplace" className="dropdown-item" role="menuitem">
{t("market")}
</Link>
<Link href="/rares" className="dropdown-item" role="menuitem">
{t("rareValues")}
</Link>
<Link href="/shop" className="dropdown-item" role="menuitem">{t("shop")}</Link>
<Link href="/marketplace" className="dropdown-item" role="menuitem">{t("market")}</Link>
<Link href="/rares" className="dropdown-item" role="menuitem">{t("rareValues")}</Link>
</NavDropdown>
<Link href="/leaderboard" className="nav-item" role="menuitem">
<Image
src="/assets/images/icons/navigation/leaderboards.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<Image src="/assets/images/icons/navigation/leaderboards.png" alt="" width={20} height={20} className="mr-1.5" />
{t("leaderboards")}
</Link>
<Link href="/radio" className="nav-item" role="menuitem">
<Image
src="/assets/images/icons/navigation/bb.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<Image src="/assets/images/icons/navigation/bb.png" alt="" width={20} height={20} className="mr-1.5" />
{t("radio")}
</Link>
<NavDropdown label={t("assistance")} icon="/assets/images/icons/navigation/rules.gif">
<Link href="/help" className="dropdown-item" role="menuitem">
{t("helpCenter")}
</Link>
<Link href="/help" className="dropdown-item" role="menuitem">{t("helpCenter")}</Link>
{session?.user ? (
<Link href="/help/tickets" className="dropdown-item" role="menuitem">
{t("myTickets")}
</Link>
<Link href="/help/tickets" className="dropdown-item" role="menuitem">{t("myTickets")}</Link>
) : null}
<Link href="/badges" className="dropdown-item" role="menuitem">
{t("badges")}
</Link>
<Link href="/draw-badge" className="dropdown-item" role="menuitem">
{t("drawBadge")}
</Link>
<Link href="/badges" className="dropdown-item" role="menuitem">{t("badges")}</Link>
<Link href="/draw-badge" className="dropdown-item" role="menuitem">{t("drawBadge")}</Link>
</NavDropdown>
{session?.user ? (
<>
<Link href="/friends" className="nav-item">
<Image
src="/assets/images/icons/navigation/goody.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<Image src="/assets/images/icons/navigation/goody.png" alt="" width={20} height={20} className="mr-1.5" />
{t("friends")}
</Link>
<Link href="/messages" className="nav-item">
<Image
src="/assets/images/icons/navigation/bb.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<Image src="/assets/images/icons/navigation/bb.png" alt="" width={20} height={20} className="mr-1.5" />
{t("messages")}
</Link>
</>
@@ -142,21 +79,12 @@ export async function Navigation() {
{isStaff ? (
<Link href="/admin" className="nav-item">
<Image
src="/assets/images/icons/navigation/home.png"
alt=""
width={20}
height={20}
className="mr-1.5"
/>
<Image src="/assets/images/icons/navigation/home.png" alt="" width={20} height={20} className="mr-1.5" />
{t("admin")}
</Link>
) : null}
<div
className="flex items-center gap-x-1 pt-2 mt-2 border-t md:hidden"
style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)" }}
>
<div className="flex items-center gap-x-1 pt-2 mt-2 border-t md:hidden" style={{ borderColor: "color-mix(in srgb, var(--color-text-muted) 10%, transparent)" }}>
<LanguageSwitcher />
<NavbarColorPicker />
<ThemeSwitcher />
+7 -2
View File
@@ -136,7 +136,9 @@ export const HABBO_FONT_GROUPS: HabboFontGroup[] = [
},
{
groupName: "Custom creations",
fonts: [{ name: "Jurrasic", styleName: "jurrasic" }],
fonts: [
{ name: "Jurrasic", styleName: "jurrasic" },
],
},
{
groupName: "Halloween fonts",
@@ -209,8 +211,11 @@ export const HABBO_FONT_GROUPS: HabboFontGroup[] = [
},
{
groupName: "Special fonts",
fonts: [{ name: "Relax", styleName: "relax" }],
fonts: [
{ name: "Relax", styleName: "relax" },
],
},
];
export function buildFontUrl(styleName: string, text: string): string {
+11 -17
View File
@@ -60,9 +60,7 @@ export default function LogoGenerator() {
if (!cancelled) setFontError(true);
});
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [styleName, safeText]);
const handleDownload = useCallback(() => {
@@ -83,10 +81,7 @@ export default function LogoGenerator() {
if (!canvas) return;
setSaveStatus("saving");
canvas.toBlob((blob) => {
if (!blob) {
setSaveStatus("error");
return;
}
if (!blob) { setSaveStatus("error"); return; }
const file = new File([blob], "logo.png", { type: "image/png" });
const fd = new FormData();
fd.append("file", file);
@@ -115,16 +110,11 @@ export default function LogoGenerator() {
try {
const info = await getFontInfo(font.styleName);
const canvas = renderToCanvas(info.chars, info.h, info.top, info.sheet, safeText, info.avgW);
if (!canvas) {
fail++;
continue;
}
if (!canvas) { fail++; continue; }
const blob = await new Promise<Blob | null>((r) => canvas.toBlob((b) => r(b), "image/png"));
if (blob) folder.file(`${font.styleName}.png`, blob);
else fail++;
} catch {
fail++;
}
} catch { fail++; }
}
const content = await zip.generateAsync({ type: "blob" });
@@ -208,7 +198,12 @@ export default function LogoGenerator() {
? "❌ Error"
: "💾 Save as site logo"}
</button>
<button type="button" className="btn btn-secondary" onClick={handleDownloadAll} disabled={zipping}>
<button
type="button"
className="btn btn-secondary"
onClick={handleDownloadAll}
disabled={zipping}
>
{zipping ? "⏳ Creating ZIP..." : "📦 Download all fonts"}
</button>
</div>
@@ -224,8 +219,7 @@ export default function LogoGenerator() {
borderRadius: "12px",
border: "1px dashed color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
overflow: "auto",
background:
"repeating-conic-gradient(color-mix(in srgb, var(--color-text-muted) 14%, transparent) 0% 25%, transparent 0% 50%) 50% / 22px 22px",
background: "repeating-conic-gradient(color-mix(in srgb, var(--color-text-muted) 14%, transparent) 0% 25%, transparent 0% 50%) 50% / 22px 22px",
}}
>
{fontError ? (
+5 -1
View File
@@ -66,7 +66,11 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
aria-label={p.alt}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={p.url} alt={p.alt} style={{ width: "100%", height: "auto", display: "block" }} />
<img
src={p.url}
alt={p.alt}
style={{ width: "100%", height: "auto", display: "block" }}
/>
<div style={{ padding: "0.7rem 0.85rem" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.date}
+8 -3
View File
@@ -80,7 +80,8 @@ function parseNowPlaying(data: unknown): string {
if (np) return np;
// { song, artist } / { title, artist } at the top level.
const top = joinSongArtist(data.song, data.artist) ?? joinSongArtist(data.title, data.artist);
const top =
joinSongArtist(data.song, data.artist) ?? joinSongArtist(data.title, data.artist);
if (top) return top;
// AzureCast: { now_playing: { song: { title, artist, text } } }.
@@ -277,7 +278,9 @@ export default function RadioPlayer() {
height: "8px",
borderRadius: "999px",
background: playing ? "#16a34a" : muted,
boxShadow: playing ? "0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent)" : "none",
boxShadow: playing
? "0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent)"
: "none",
}}
/>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
@@ -301,7 +304,9 @@ export default function RadioPlayer() {
</div>
<span
aria-label={listeners != null ? `${listeners.toLocaleString()} listeners` : "Listeners"}
aria-label={
listeners != null ? `${listeners.toLocaleString()} listeners` : "Listeners"
}
style={{
flex: "none",
display: "inline-flex",
+3 -9
View File
@@ -6,10 +6,7 @@ interface CharInfo {
w: number;
}
const fontCache = new Map<
string,
{ chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }
>();
const fontCache = new Map<string, { chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }>();
function isTransparent(data: Uint8ClampedArray, x: number, y: number, w: number): boolean {
return data[(y * w + x) * 4 + 3] === 0;
@@ -48,9 +45,7 @@ function scanCharWidths(data: Uint8ClampedArray, sw: number, sh: number): CharIn
return chars;
}
export async function getFontInfo(
font: string,
): Promise<{ chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }> {
export async function getFontInfo(font: string): Promise<{ chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }> {
const cached = fontCache.get(font);
if (cached) return cached;
@@ -73,8 +68,7 @@ export async function getFontInfo(
const chars = scanCharWidths(imageData.data, canvas.width, canvas.height);
// measure total height by finding first/last non-empty row
let top = canvas.height,
bottom = 0;
let top = canvas.height, bottom = 0;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
if (!isTransparent(imageData.data, x, y, canvas.width)) {
+14 -2
View File
@@ -48,7 +48,15 @@ export function ContentCard({
}
/** A value + label tile (profile stats, rare-value summaries, …). */
export function StatBlock({ value, label, icon }: { value: ReactNode; label: string; icon?: string }) {
export function StatBlock({
value,
label,
icon,
}: {
value: ReactNode;
label: string;
icon?: string;
}) {
return (
<div className="stat-block">
<div className="stat-block-value">
@@ -88,7 +96,9 @@ export function OnlineBadge({ online }: { online: boolean }) {
export function RankBadge({ position }: { position: number }) {
const medal = position === 1 ? "🥇" : position === 2 ? "🥈" : position === 3 ? "🥉" : null;
return (
<span className={`rank-badge rank-${position <= 3 ? position : "n"}`}>{medal ?? `#${position}`}</span>
<span className={`rank-badge rank-${position <= 3 ? position : "n"}`}>
{medal ?? `#${position}`}
</span>
);
}
@@ -124,3 +134,5 @@ export function SkeletonCard({ lines = 3 }: { lines?: number }) {
</div>
);
}
+14 -34
View File
@@ -17,13 +17,14 @@ export async function SiteHeader() {
let online: number;
try {
online = await cached("online_count", 10_000, () => prisma.user.count({ where: { online: "1" } }));
online = await cached("online_count", 10_000, () =>
prisma.user.count({ where: { online: "1" } }),
);
} catch {
online = 0;
}
const bannerUrl =
header && header !== "/assets/images/background.png" ? header : "/assets/images/banner.png";
const bannerUrl = header && header !== "/assets/images/background.png" ? header : "/assets/images/banner.png";
return (
<div className="site-header relative w-full overflow-hidden" style={{ minHeight: "18rem" }}>
@@ -31,46 +32,25 @@ export async function SiteHeader() {
<div className="absolute inset-0" style={{ background: `url(${bannerUrl}) center/cover no-repeat` }} />
{/* Gradient overlays */}
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(135deg, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.25) 35%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.4) 75%, rgba(0,0,0,0.7) 100%)",
}}
/>
<div className="absolute inset-0" style={{
background: "linear-gradient(135deg, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.25) 35%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.4) 75%, rgba(0,0,0,0.7) 100%)",
}} />
{/* Subtle dot pattern */}
<div
className="absolute inset-0 opacity-[0.04]"
style={{
backgroundImage: "radial-gradient(rgba(255,255,255,0.5) 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
/>
<div className="absolute inset-0 opacity-[0.04]" style={{
backgroundImage: "radial-gradient(rgba(255,255,255,0.5) 1px, transparent 1px)",
backgroundSize: "20px 20px",
}} />
{/* Decorative glows */}
<div
className="absolute -top-24 -right-24 w-96 h-96 rounded-full opacity-[0.12] blur-[120px]"
style={{ background: "var(--color-primary)" }}
/>
<div
className="absolute -bottom-24 -left-24 w-72 h-72 rounded-full opacity-[0.08] blur-[100px]"
style={{ background: "var(--color-primary)" }}
/>
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full opacity-[0.12] blur-[120px]" style={{ background: "var(--color-primary)" }} />
<div className="absolute -bottom-24 -left-24 w-72 h-72 rounded-full opacity-[0.08] blur-[100px]" style={{ background: "var(--color-primary)" }} />
{/* Content */}
<div className="relative flex flex-col items-center justify-center px-4 py-12 min-h-[18rem]">
<Link href="/" className="transition-transform duration-300 hover:scale-105 mb-8">
{logo ? (
<Image
className="drop-shadow-2xl block"
src={logo}
alt={hotelName ?? "Hotel"}
width={200}
height={120}
style={{ maxHeight: 120, width: "auto", height: "auto" }}
unoptimized
/>
<Image className="drop-shadow-2xl block" src={logo} alt={hotelName ?? "Hotel"} width={200} height={120} style={{ maxHeight: 120, width: "auto", height: "auto" }} unoptimized />
) : (
<h1
className="text-center font-extrabold tracking-tight leading-none text-white"
+2 -2
View File
@@ -9,8 +9,8 @@ import { useState } from "react";
* theme before paint to avoid a flash — this component only reflects/toggles it.
*/
export function ThemeSwitcher() {
const [dark, setDark] = useState(
() => typeof window !== "undefined" && document.documentElement.classList.contains("dark"),
const [dark, setDark] = useState(() =>
typeof window !== "undefined" && document.documentElement.classList.contains("dark"),
);
function toggle() {