Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82a4f9b645 | ||
|
|
82d5a36497 | ||
|
|
699b89e916 | ||
|
|
0d35ad56f2 |
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user