Files
EpicNext-Cms/src/components/public/article-slider.tsx
T
Simo 4dfe698009 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.
2026-06-29 18:26:34 +02:00

144 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}