Revert "Add missing admin action files and navigation links"
This reverts commit 41be6835bf.
This commit is contained in:
1 parent
4a1e1115b3
commit
4d515bc400
338 files changed
+5949
-28144
No files matched your search
@@ -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}/`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user