- 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.
144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|