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,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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user