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:
1 parent
f7b3845131
commit
4dfe698009
25 files changed
+1463
-64
No files matched your search
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user