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