Close remaining web gaps: rich profile, login history, lightbox/slider, flash client, admin chatlog/DJ/chart/WYSIWYG, niche API

- Rich profile (/u/[username]): wallet (credits/duckets/diamonds), friends
  grid (messenger_friendships), and owned rooms sections.
- Login history: new website_login_logs table (model + migration 0007),
  recorded on every successful sign-in (ip + user-agent), surfaced on a new
  /settings/sessions page (with failed-attempt list from failed_logins).
- Photos lightbox + home article slider (client components, no Swiper dep).
- /client/flash launcher (SSO ticket like the Nitro page).
- Admin: private chatlogs section in /admin/logs, /admin/radio/moderation
  (shout moderation), a "users by rank" inline bar chart on the dashboard,
  and a TinyMCE rich-text editor on the article admin forms.
- Niche API: /api/values/[id], /api/guilds(+/[id]), /api/radio/auto-play.

Verified live (prod, amx_test): login recorded → /settings/sessions shows
it with device; profile renders wallet/friends/rooms; dashboard chart +
private-chat logs + /client/flash + /api/guilds all OK. Reverted test data.
tsc 0, vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-29 18:26:34 +02:00
1 parent f7b3845131
commit 4dfe698009
25 files changed
+1463 -64

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
"use client";
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";
type TinyMCEGlobal = {
init: (opts: Record<string, unknown>) => Promise<unknown>;
remove: (selector: string) => void;
};
declare global {
interface Window {
tinymce?: TinyMCEGlobal;
}
}
let loaderPromise: Promise<TinyMCEGlobal | null> | null = null;
/** Inject the TinyMCE script once; resolves null if it fails to load. */
function loadTinyMce(): Promise<TinyMCEGlobal | null> {
if (typeof window === "undefined") return Promise.resolve(null);
if (window.tinymce) return Promise.resolve(window.tinymce);
if (loaderPromise) return loaderPromise;
loaderPromise = new Promise((resolve) => {
const existing = document.querySelector<HTMLScriptElement>("script[data-tinymce]");
const finish = () => resolve(window.tinymce ?? null);
if (existing) {
existing.addEventListener("load", finish, { once: true });
existing.addEventListener("error", () => resolve(null), { once: true });
if (window.tinymce) resolve(window.tinymce);
return;
}
const script = document.createElement("script");
script.src = TINYMCE_SRC;
script.async = true;
script.referrerPolicy = "origin";
script.dataset.tinymce = "1";
script.addEventListener("load", finish, { once: true });
script.addEventListener("error", () => resolve(null), { once: true });
document.head.appendChild(script);
});
return loaderPromise;
}
/**
* WYSIWYG editor over a plain <textarea>. Keeps the original `name` so the
* surrounding server-action form still receives the HTML on submit. If TinyMCE
* fails to load (offline, CDN blocked) it silently degrades to the textarea.
*/
export function RichText({
name,
defaultValue = "",
placeholder,
rows = 10,
}: {
name: string;
defaultValue?: string;
placeholder?: string;
rows?: number;
}) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const reactId = useId();
// Stable, querySelector-safe id (useId() contains ':').
const editorId = `rt-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
const selector = `#${editorId}`;
loadTinyMce().then((tinymce) => {
if (cancelled || !tinymce || !textareaRef.current) return;
tinymce
.init({
selector,
menubar: false,
height: Math.max(rows * 22, 240),
plugins: "link lists code autolink",
toolbar:
"undo redo | bold italic underline | bullist numlist | link | removeformat | code",
branding: false,
promotion: false,
license_key: "gpl",
skin: "oxide",
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;
}) => {
const sync = () => {
if (textareaRef.current) textareaRef.current.value = editor.getContent();
};
editor.on("change keyup undo redo SetContent", sync);
},
})
.then(() => {
if (!cancelled) setReady(true);
})
.catch(() => {
/* leave the plain textarea in place */
});
});
return () => {
cancelled = true;
// Detach the editor instance on unmount; ignore if TinyMCE never loaded.
try {
window.tinymce?.remove(selector);
} catch {
/* noop */
}
};
}, [editorId, rows]);
return (
<textarea
id={editorId}
ref={textareaRef}
name={name}
defaultValue={defaultValue}
placeholder={placeholder}
rows={rows}
// Hide the raw textarea only once TinyMCE has mounted (it replaces it with
// its own iframe); until then the plain field is the visible fallback.
style={ready ? { display: "none" } : undefined}
/>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Horizontal, scroll-snap slider for the home page "latest news" row. Keeps the
* exact article-card markup from the server page; the cards are passed in as a
* pre-shaped list so this client component carries no prisma/i18n dependency.
* Prev/next buttons scroll the snap track by one viewport-ish step and disable
* themselves at the ends.
*/
export type SliderArticle = {
slug: string;
title: string;
image: string;
excerpt: string;
};
export function ArticleSlider({ articles }: { articles: SliderArticle[] }) {
const trackRef = useRef<HTMLDivElement>(null);
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(false);
const updateEdges = useCallback(() => {
const el = trackRef.current;
if (!el) return;
const maxScroll = el.scrollWidth - el.clientWidth;
setAtStart(el.scrollLeft <= 1);
setAtEnd(el.scrollLeft >= maxScroll - 1);
}, []);
useEffect(() => {
updateEdges();
window.addEventListener("resize", updateEdges);
return () => window.removeEventListener("resize", updateEdges);
}, [updateEdges]);
const scrollByDir = useCallback((dir: 1 | -1) => {
const el = trackRef.current;
if (!el) return;
// Step by roughly one card width (incl. gap) so snapping lands cleanly.
const step = Math.max(el.clientWidth * 0.8, 260);
el.scrollBy({ left: dir * step, behavior: "smooth" });
}, []);
return (
<div style={{ position: "relative" }}>
<button
type="button"
aria-label="Previous"
onClick={() => scrollByDir(-1)}
disabled={atStart}
className="article-slider-nav"
style={{ left: "-0.6rem", opacity: atStart ? 0.35 : 1 }}
>
‹
</button>
<div
ref={trackRef}
onScroll={updateEdges}
style={{
display: "flex",
gap: "1rem",
overflowX: "auto",
scrollSnapType: "x mandatory",
scrollbarWidth: "none",
paddingBottom: "0.25rem",
}}
>
{articles.map((a) => (
<article
key={a.slug}
className="card hover"
style={{
padding: 0,
overflow: "hidden",
flex: "0 0 auto",
width: "clamp(220px, 70vw, 280px)",
scrollSnapAlign: "start",
}}
>
{a.image ? (
// biome-ignore lint/performance/noImgElement: external article image
<img className="article-img" src={a.image} alt="" />
) : (
<div className="article-img" />
)}
<div style={{ padding: "0.85rem" }}>
<h3 style={{ margin: "0 0 0.3rem", fontSize: "1rem" }}>
<Link href={`/news/${a.slug}`}>{a.title}</Link>
</h3>
<p className="muted" style={{ margin: 0 }}>
{a.excerpt}
</p>
</div>
</article>
))}
</div>
<button
type="button"
aria-label="Next"
onClick={() => scrollByDir(1)}
disabled={atEnd}
className="article-slider-nav"
style={{ right: "-0.6rem", opacity: atEnd ? 0.35 : 1 }}
>
›
</button>
<style>{`
.article-slider-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 2.4rem;
height: 2.4rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-card);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.15s ease, box-shadow 0.15s ease;
}
.article-slider-nav:hover:not(:disabled) {
box-shadow: var(--shadow-card-hover);
}
.article-slider-nav:disabled {
cursor: default;
}
`}</style>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useCallback, useEffect, useState } from "react";
/**
* Client-side photo gallery for the public Photos page. Renders the same
* card grid as before, but each tile is now a button that opens the photo in
* a full-screen lightbox overlay. The overlay is a fixed, full-viewport layer
* toggled by state — closes on backdrop click, on the ✕ button, or with Esc.
*
* Photos are pre-shaped on the server (caption strings already translated) so
* this component carries no prisma/i18n dependency.
*/
export type LightboxPhoto = {
id: string;
url: string;
alt: string;
date: string;
user: string;
};
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
// Index of the photo shown in the lightbox, or null when closed.
const [openIndex, setOpenIndex] = useState<number | null>(null);
const active = openIndex !== null ? photos[openIndex] : null;
const close = useCallback(() => setOpenIndex(null), []);
// Esc-to-close + lock background scroll while the overlay is open.
useEffect(() => {
if (active == null) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [active, close]);
return (
<>
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
{photos.map((p, i) => (
<button
key={p.id}
type="button"
onClick={() => setOpenIndex(i)}
className="card hover"
style={{
padding: 0,
overflow: "hidden",
border: 0,
background: "var(--color-surface)",
cursor: "zoom-in",
textAlign: "left",
font: "inherit",
color: "inherit",
display: "block",
width: "100%",
}}
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" }}
/>
<div style={{ padding: "0.7rem 0.85rem" }}>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.date}
</p>
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{p.user}
</p>
</div>
</button>
))}
</div>
{active ? (
<div
onClick={close}
role="dialog"
aria-modal="true"
aria-label={active.alt}
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem",
background: "rgba(0, 0, 0, 0.82)",
backdropFilter: "blur(2px)",
cursor: "zoom-out",
}}
>
<button
type="button"
onClick={close}
aria-label="Close"
style={{
position: "absolute",
top: "1rem",
right: "1rem",
width: "2.5rem",
height: "2.5rem",
borderRadius: "999px",
border: 0,
background: "rgba(255, 255, 255, 0.16)",
color: "#fff",
fontSize: "1.4rem",
lineHeight: 1,
cursor: "pointer",
}}
>
✕
</button>
{/* Stop propagation so clicking the image itself doesn't close. */}
<figure
onClick={(e) => e.stopPropagation()}
style={{
margin: 0,
maxWidth: "min(92vw, 1100px)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "0.75rem",
cursor: "default",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={active.url}
alt={active.alt}
style={{
maxWidth: "100%",
maxHeight: "78vh",
width: "auto",
height: "auto",
display: "block",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-card-hover)",
}}
/>
<figcaption
style={{
color: "rgba(255, 255, 255, 0.85)",
fontSize: "0.9rem",
textAlign: "center",
}}
>
{active.date} · {active.user}
</figcaption>
</figure>
</div>
) : null}
</>
);
}
+3
View File
@@ -105,6 +105,9 @@ export async function TopHeader() {
<Link href="/settings/2fa" className="dropdown-item">
Two-factor
</Link>
<Link href="/settings/sessions" className="dropdown-item">
Login activity
</Link>
<form
action={async () => {
"use server";