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}
/>
);
}