Compare commits

..
4 Commits
Author SHA1 Message Date
hypercross 82a4f9b645 fix(web): stop pdf viewer overflowing 2026-08-14 13:55:02 +08:00
hypercross 82d5a36497 feat(web): add search link to mod header 2026-08-14 13:53:05 +08:00
hypercross 699b89e916 feat(web): show child count in object tree nodes 2026-08-14 12:05:46 +08:00
hypercross 0d35ad56f2 feat(web): rework deck carousel navigation
Replace the counter with a sliding-dot pagination: dots slide and fade
as the active card changes, a counter above the bar shows the current
index, and round first/last and prev/next buttons flank the strip.
Scrolling over the dots steps through the deck. Also fix the camera fit
so it frames the active card front-on instead of the carousel center.
2026-08-14 12:05:37 +08:00
4 changed files with 110 additions and 20 deletions
+3
View File
@@ -44,6 +44,9 @@ export default function ModHeader() {
</div>
</div>
<nav className="flex shrink-0 gap-4 text-sm text-zinc-400">
<Link to="/" className="hover:text-zinc-100">
Search
</Link>
<a
href={modFileUrl(id!, item?.fileUrl)}
className="hover:text-zinc-100"
+10
View File
@@ -150,6 +150,16 @@ function TreeNode({
))}
</span>
<span className="truncate">{node.label}</span>
{hasChildren && (
<span
title={`${node.children.length} ${
node.children.length === 1 ? 'child' : 'children'
}`}
className="ml-1.5 shrink-0 rounded bg-zinc-800 px-1 font-mono text-[10px] leading-4 text-zinc-500"
>
{node.children.length}
</span>
)}
</button>
</div>
{hasChildren && expanded && (
+95 -18
View File
@@ -1,5 +1,5 @@
import { useMemo, useRef, useState } from 'react';
import type { RefObject } from 'react';
import type { RefObject, WheelEvent } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
@@ -223,7 +223,14 @@ function arcRadius(cardWidth: number | null): number {
return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP));
}
/** Prev/next controls and a counter, rendered as the scene overlay. */
/**
* Prev/next controls and a sliding-dot pagination, rendered as the scene
* overlay. All dots live in a fixed-width track that slides so the active dot
* stays centered; dots fade out as they leave the window. A counter above the
* bar shows the current index (`1 / 51`). Round first/last and prev/next
* buttons flank the dots. Scrolling over the pagination steps through the
* deck.
*/
function CarouselControls({
active,
count,
@@ -235,25 +242,95 @@ function CarouselControls({
}) {
const prev = () => onStep(-1);
const next = () => onStep(1);
const first = () => onStep(-active);
const last = () => onStep(count - 1 - active);
// Dots visible on each side of the active dot.
const DOT_WINDOW = 4;
const DOT_SIZE = 8;
const ACTIVE_WIDTH = 26;
const GAP = 6;
const SLOTS = DOT_WINDOW * 2 + 1;
const slotW = DOT_SIZE + GAP;
// Extra width the active dot (plus its gap) pushes beyond a normal slot.
const extra = ACTIVE_WIDTH + GAP - slotW;
const trackW = SLOTS * slotW + extra;
// Left offset of a dot at slot `s` (0 = leftmost). Dots after the active
// slot shift right by `extra` to make room for the wider active dot.
const slotLeft = (s: number) => s * slotW + (s > DOT_WINDOW ? extra : 0);
const dots = Array.from({ length: count }, (_, i) => i);
const onWheel = (e: React.WheelEvent) => {
// Scroll up/left goes back, scroll down/right goes forward.
onStep(e.deltaY < 0 ? -1 : 1);
};
const roundBtn =
'flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-zinc-900/80 disabled:hover:text-zinc-300';
return (
<div className="absolute inset-x-0 bottom-2 z-10 flex items-center justify-center gap-3">
<button
onClick={prev}
aria-label="Previous card"
className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
</button>
<span className="rounded-md bg-zinc-900/80 px-3 py-1 font-mono text-xs text-zinc-300 backdrop-blur">
<div className="absolute inset-x-0 bottom-2 z-10 flex flex-col items-center gap-2">
<span className="rounded-md bg-zinc-900/80 px-2 py-0.5 font-mono text-xs text-zinc-300 backdrop-blur">
{active + 1} / {count}
</span>
<button
onClick={next}
aria-label="Next card"
className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
>
</button>
<div className="flex items-center gap-2">
<button
onClick={first}
disabled={active === 0}
aria-label="First card"
className={roundBtn}
>
«
</button>
<button onClick={prev} aria-label="Previous card" className={roundBtn}>
</button>
<div
onWheel={onWheel}
role="group"
aria-label="Browse cards"
className="flex items-center rounded-full bg-zinc-900/80 px-2 py-1.5 backdrop-blur"
>
<div
className="relative overflow-hidden"
style={{ width: trackW, height: DOT_SIZE }}
>
{dots.map((i) => {
const isActive = i === active;
const slot = DOT_WINDOW + (i - active);
const inWindow = slot >= 0 && slot < SLOTS;
return (
<button
key={i}
onClick={() => onStep(i - active)}
aria-label={`Go to card ${i + 1}`}
aria-current={isActive ? 'true' : undefined}
className="absolute top-0 flex items-center justify-center rounded-full transition-all duration-300"
style={{
left: slotLeft(slot),
width: isActive ? ACTIVE_WIDTH : DOT_SIZE,
height: DOT_SIZE,
opacity: inWindow ? 1 : 0,
pointerEvents: inWindow ? 'auto' : 'none',
backgroundColor: isActive ? '#f4f4f5' : '#71717a',
}}
/>
);
})}
</div>
</div>
<button onClick={next} aria-label="Next card" className={roundBtn}>
</button>
<button
onClick={last}
disabled={active === count - 1}
aria-label="Last card"
className={roundBtn}
>
»
</button>
</div>
</div>
);
}
@@ -21,7 +21,7 @@ export default function PdfViewer({ object }: { object: TTSObject }) {
const proxied = pdfUrl(url);
return (
<div className="space-y-3">
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-mono text-xs text-zinc-500">{url}</p>
<a
@@ -36,7 +36,7 @@ export default function PdfViewer({ object }: { object: TTSObject }) {
<iframe
src={proxied}
title="PDF preview"
className="aspect-[3/4] w-full rounded-lg border border-zinc-800 bg-zinc-950"
className="min-h-0 w-full flex-1 rounded-lg border border-zinc-800 bg-zinc-950"
/>
</div>
);