ui fixes
This commit is contained in:
@@ -8,7 +8,6 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { ReadingSettings } from "@/components/reading-settings";
|
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -389,22 +388,17 @@ export function GlossaryClient({
|
|||||||
<>
|
<>
|
||||||
{/* Sticky header */}
|
{/* Sticky header */}
|
||||||
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
||||||
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex-1 min-w-0">
|
<Link
|
||||||
<Link
|
href="/"
|
||||||
href="/"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
>
|
||||||
>
|
← Home
|
||||||
← Home
|
</Link>
|
||||||
</Link>
|
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
<span className="text-primary">Glossary: </span>
|
||||||
<span className="text-primary">Glossary: </span>
|
{activeEntry?.term || activeLetter}
|
||||||
{activeEntry?.term || activeLetter}
|
</h1>
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 pt-1">
|
|
||||||
<ReadingSettings />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ export default function RootLayout({
|
|||||||
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
|
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
{children}
|
{children}
|
||||||
<SiteFooter />
|
|
||||||
</div>
|
</div>
|
||||||
<SiteBottomNav />
|
<SiteBottomNav />
|
||||||
<TextSelectionToolbar />
|
<TextSelectionToolbar />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState, useMemo, useRef } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { ReadingSettings } from "@/components/reading-settings";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -451,29 +450,24 @@ export function FilterYearBrowser({
|
|||||||
<>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
||||||
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex-1 min-w-0">
|
<Link
|
||||||
<Link
|
href={backHref}
|
||||||
href={backHref}
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
>
|
||||||
>
|
← {backLabel}
|
||||||
← {backLabel}
|
</Link>
|
||||||
</Link>
|
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
<span className="text-primary">
|
||||||
<span className="text-primary">
|
{titlePrefix} {filterName}:{" "}
|
||||||
{titlePrefix} {filterName}:{" "}
|
</span>
|
||||||
</span>
|
{activeYearLabel}
|
||||||
{activeYearLabel}
|
</h1>
|
||||||
</h1>
|
<p className="mt-1 text-muted-foreground">
|
||||||
<p className="mt-1 text-muted-foreground">
|
{activeGroup?.count ?? 0}{" "}
|
||||||
{activeGroup?.count ?? 0}{" "}
|
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} in{" "}
|
||||||
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} in{" "}
|
{activeYearLabel} · {totalCount} total
|
||||||
{activeYearLabel} · {totalCount} total
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 pt-1">
|
|
||||||
<ReadingSettings />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState, useMemo, useRef } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { ReadingSettings } from "@/components/reading-settings";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -356,27 +355,22 @@ export function YearMonthBrowser({
|
|||||||
<>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
||||||
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex-1 min-w-0">
|
<Link
|
||||||
<Link
|
href="/letters?view=year"
|
||||||
href="/letters?view=year"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
>
|
||||||
>
|
← Letters by Year
|
||||||
← Letters by Year
|
</Link>
|
||||||
</Link>
|
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
<span className="text-primary">Letters: </span>
|
||||||
<span className="text-primary">Letters: </span>
|
{activeLabel} {year}
|
||||||
{activeLabel} {year}
|
</h1>
|
||||||
</h1>
|
<p className="mt-1 text-muted-foreground">
|
||||||
<p className="mt-1 text-muted-foreground">
|
{activeGroup?.count ?? 0}{" "}
|
||||||
{activeGroup?.count ?? 0}{" "}
|
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "}
|
||||||
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "}
|
{totalCount} total in {year}
|
||||||
{totalCount} total in {year}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 pt-1">
|
|
||||||
<ReadingSettings />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export default async function TranscriptionsPage({
|
|||||||
Transcriptions
|
Transcriptions
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">
|
||||||
{totalCount.toLocaleString()} lectures & conversations of Śrīla
|
{totalCount.toLocaleString()} lectures & conversations of
|
||||||
Prabhupāda (1966–1977)
|
Śrīla Prabhupāda (1966–1977)
|
||||||
</p>
|
</p>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { ReadingSettings } from "@/components/reading-settings";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
|
||||||
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
|
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
|
||||||
@@ -477,29 +476,24 @@ export function TranscriptionTypeBrowser({
|
|||||||
<>
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
<header className="border-b border-border bg-card px-6 py-4 sticky top-0 z-20">
|
||||||
<div className="max-w-5xl mx-auto flex items-start justify-between gap-4">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex-1 min-w-0">
|
<Link
|
||||||
<Link
|
href="/transcriptions?view=type"
|
||||||
href="/transcriptions?view=type"
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
>
|
||||||
>
|
← Transcriptions by Type
|
||||||
← Transcriptions by Type
|
</Link>
|
||||||
</Link>
|
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
<span className="text-primary">{typeLabel}: </span>
|
||||||
<span className="text-primary">{typeLabel}: </span>
|
{activeYearLabel}
|
||||||
{activeYearLabel}
|
</h1>
|
||||||
</h1>
|
<p className="mt-1 text-muted-foreground">
|
||||||
<p className="mt-1 text-muted-foreground">
|
{activeGroup?.count ?? 0}{" "}
|
||||||
{activeGroup?.count ?? 0}{" "}
|
{(activeGroup?.count ?? 0) === 1
|
||||||
{(activeGroup?.count ?? 0) === 1
|
? "transcription"
|
||||||
? "transcription"
|
: "transcriptions"}{" "}
|
||||||
: "transcriptions"}{" "}
|
in {activeYearLabel} · {totalCount} total
|
||||||
in {activeYearLabel} · {totalCount} total
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 pt-1">
|
|
||||||
<ReadingSettings />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useCallback, useEffect, useState } from "react";
|
import { useRef, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useAudioPlayer } from "./audio-context";
|
import { useAudioPlayer } from "./audio-context";
|
||||||
import { AudioWaveAnimation } from "./audio-wave";
|
import { AudioWaveAnimation } from "./audio-wave";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence, useMotionValue } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -84,6 +84,59 @@ function useAutoMinimize(hasTrack: boolean) {
|
|||||||
|
|
||||||
const FALLBACK_COVER = "/images/cover.png";
|
const FALLBACK_COVER = "/images/cover.png";
|
||||||
|
|
||||||
|
// Minimized pill (size-14 = 3.5rem = 56px) drag + persistence.
|
||||||
|
const MINI_PILL_SIZE = 56;
|
||||||
|
const MINI_EDGE_MARGIN = 16; // matches the old `right-4 bottom-6` resting state
|
||||||
|
const MINI_POS_STORAGE_KEY = "spsays:audio-mini-pos";
|
||||||
|
// Past this drag distance (px), treat the gesture as "drag" and suppress
|
||||||
|
// the click-to-expand that would otherwise fire on pointer-up.
|
||||||
|
const MINI_DRAG_THRESHOLD = 4;
|
||||||
|
|
||||||
|
type MiniPos = { x: number; y: number };
|
||||||
|
|
||||||
|
function clampMiniPos(p: MiniPos): MiniPos {
|
||||||
|
if (typeof window === "undefined") return p;
|
||||||
|
const maxX = window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN;
|
||||||
|
const maxY = window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN;
|
||||||
|
return {
|
||||||
|
x: Math.max(MINI_EDGE_MARGIN, Math.min(maxX, p.x)),
|
||||||
|
y: Math.max(MINI_EDGE_MARGIN, Math.min(maxY, p.y)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMiniPos(): MiniPos {
|
||||||
|
// Resting position when nothing's been persisted yet: bottom-right
|
||||||
|
// corner, matching the old static layout (`right-4 bottom-6`).
|
||||||
|
if (typeof window === "undefined") return { x: 0, y: 0 };
|
||||||
|
return {
|
||||||
|
x: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||||
|
y: window.innerHeight - MINI_PILL_SIZE - 24,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMiniPos(): MiniPos | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(MINI_POS_STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const p = JSON.parse(raw);
|
||||||
|
if (typeof p?.x !== "number" || typeof p?.y !== "number") return null;
|
||||||
|
return clampMiniPos(p);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMiniPos(p: MiniPos) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MINI_POS_STORAGE_KEY, JSON.stringify(p));
|
||||||
|
} catch {
|
||||||
|
// Storage may be unavailable (Safari private mode, quota). Drop the
|
||||||
|
// write silently — drag-and-position still works for the session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -295,6 +348,57 @@ export function FloatingPlayer() {
|
|||||||
const { minimized, setMinimized, resetTimer } =
|
const { minimized, setMinimized, resetTimer } =
|
||||||
useAutoMinimize(!!currentTrack);
|
useAutoMinimize(!!currentTrack);
|
||||||
|
|
||||||
|
// Minimized pill position lives in motion values so framer's `drag`
|
||||||
|
// can mutate them directly without round-tripping through React state.
|
||||||
|
// Initial position is computed once (lazy useMemo) — the player only
|
||||||
|
// renders when a track is playing, which is always after hydration, so
|
||||||
|
// window access is safe here.
|
||||||
|
const initialMiniPos = useMemo(
|
||||||
|
() => loadMiniPos() ?? defaultMiniPos(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const miniX = useMotionValue(initialMiniPos.x);
|
||||||
|
const miniY = useMotionValue(initialMiniPos.y);
|
||||||
|
// Drag constraints — recomputed each render so a window resize between
|
||||||
|
// renders gets picked up the next time the user touches the pill.
|
||||||
|
const [miniBounds, setMiniBounds] = useState<{
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}>(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { top: 0, left: 0, right: 0, bottom: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
top: MINI_EDGE_MARGIN,
|
||||||
|
left: MINI_EDGE_MARGIN,
|
||||||
|
right: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||||
|
bottom: window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Re-clamp on window resize so a saved position from a wider viewport
|
||||||
|
// doesn't strand the pill off-screen after rotation / DevTools open.
|
||||||
|
useEffect(() => {
|
||||||
|
function onResize() {
|
||||||
|
setMiniBounds({
|
||||||
|
top: MINI_EDGE_MARGIN,
|
||||||
|
left: MINI_EDGE_MARGIN,
|
||||||
|
right: window.innerWidth - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||||
|
bottom: window.innerHeight - MINI_PILL_SIZE - MINI_EDGE_MARGIN,
|
||||||
|
});
|
||||||
|
const clamped = clampMiniPos({ x: miniX.get(), y: miniY.get() });
|
||||||
|
miniX.set(clamped.x);
|
||||||
|
miniY.set(clamped.y);
|
||||||
|
saveMiniPos(clamped);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, [miniX, miniY]);
|
||||||
|
// Tracks whether the most-recent pointer interaction was a drag (vs a
|
||||||
|
// tap) so the click handler can suppress expand-on-drag-end.
|
||||||
|
const miniWasDraggedRef = useRef(false);
|
||||||
|
|
||||||
// Watch for the selection toolbar appearing/disappearing in the DOM
|
// Watch for the selection toolbar appearing/disappearing in the DOM
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTrack) return;
|
if (!currentTrack) return;
|
||||||
@@ -422,24 +526,64 @@ export function FloatingPlayer() {
|
|||||||
data-audio-player
|
data-audio-player
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
// Suppress the click that fires at the end of every drag —
|
||||||
|
// only an actual tap should expand the player.
|
||||||
|
if (miniWasDraggedRef.current) {
|
||||||
|
miniWasDraggedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
setMinimized(false);
|
setMinimized(false);
|
||||||
resetTimer();
|
resetTimer();
|
||||||
}}
|
}}
|
||||||
aria-label="Expand audio player"
|
aria-label="Expand audio player"
|
||||||
initial={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
drag
|
||||||
animate={{
|
dragMomentum={false}
|
||||||
opacity: 1,
|
dragElastic={0}
|
||||||
scale: 1,
|
dragConstraints={miniBounds}
|
||||||
bottom: selectionToolbarVisible ? 90 : 24,
|
onDragStart={() => {
|
||||||
|
miniWasDraggedRef.current = false;
|
||||||
}}
|
}}
|
||||||
exit={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
onDragEnd={(_, info) => {
|
||||||
|
// Distinguish a real drag from the small jitter that a tap
|
||||||
|
// sometimes produces — only the former should suppress click.
|
||||||
|
if (
|
||||||
|
Math.abs(info.offset.x) > MINI_DRAG_THRESHOLD ||
|
||||||
|
Math.abs(info.offset.y) > MINI_DRAG_THRESHOLD
|
||||||
|
) {
|
||||||
|
miniWasDraggedRef.current = true;
|
||||||
|
}
|
||||||
|
const clamped = clampMiniPos({
|
||||||
|
x: miniX.get(),
|
||||||
|
y: miniY.get(),
|
||||||
|
});
|
||||||
|
miniX.set(clamped.x);
|
||||||
|
miniY.set(clamped.y);
|
||||||
|
saveMiniPos(clamped);
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.5 }}
|
||||||
transition={{ type: "spring", damping: 20, stiffness: 400 }}
|
transition={{ type: "spring", damping: 20, stiffness: 400 }}
|
||||||
|
style={{
|
||||||
|
// Position purely via the motion-value transform so framer's
|
||||||
|
// drag controller mutates the same channel that paints it.
|
||||||
|
// `top: 0; left: 0` anchors the transform origin to the
|
||||||
|
// viewport corner; `x`/`y` then carry the pill anywhere.
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
x: miniX,
|
||||||
|
y: miniY,
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-40 right-4",
|
"z-40",
|
||||||
"size-14 rounded-full",
|
"size-14 rounded-full",
|
||||||
"shadow-2xl shadow-black/30",
|
"shadow-2xl shadow-black/30",
|
||||||
"overflow-hidden",
|
"overflow-hidden",
|
||||||
"ring-2 ring-white/20",
|
"ring-2 ring-white/20",
|
||||||
|
// Grab affordance + touch-action: none so the browser doesn't
|
||||||
|
// intercept touch as a page scroll and starve the drag.
|
||||||
|
"cursor-grab active:cursor-grabbing touch-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Cover image */}
|
{/* Cover image */}
|
||||||
|
|||||||
@@ -14,11 +14,16 @@
|
|||||||
* hidden and replaced with a "← Back to search results" button. This
|
* hidden and replaced with a "← Back to search results" button. This
|
||||||
* keeps the cross-page-navigation polish that the search page relies on.
|
* keeps the cross-page-navigation polish that the search page relies on.
|
||||||
*
|
*
|
||||||
|
* Sticky headers collapse on scroll-down and re-appear on scroll-up
|
||||||
|
* (mobile-address-bar pattern). Long reading pages get back the vertical
|
||||||
|
* real estate without losing the title anchor on demand. Only applies
|
||||||
|
* when `sticky` — a non-sticky header has nothing to slide behind.
|
||||||
|
*
|
||||||
* Pages that don't want a sub-header (e.g. the redesigned homepage which
|
* Pages that don't want a sub-header (e.g. the redesigned homepage which
|
||||||
* paints its own editorial hero) can simply not render PageHeader.
|
* paints its own editorial hero) can simply not render PageHeader.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -63,12 +68,39 @@ export function PageHeader({
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Scroll-direction-driven collapse. Hidden when the user is scrolling
|
||||||
|
// down past the first viewport-tenth and revealed again as soon as
|
||||||
|
// they scroll up. Near the top we always show so quick reading doesn't
|
||||||
|
// feel jumpy. Only wired up when `sticky` — that's the only case where
|
||||||
|
// the header has a fixed slot to slide behind.
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const lastScrollYRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sticky) return;
|
||||||
|
lastScrollYRef.current = window.scrollY;
|
||||||
|
function onScroll() {
|
||||||
|
const y = window.scrollY;
|
||||||
|
const delta = y - lastScrollYRef.current;
|
||||||
|
// Threshold of ~8px filters out scroll-jitter / momentum noise.
|
||||||
|
if (y < 80) setCollapsed(false);
|
||||||
|
else if (delta > 8) setCollapsed(true);
|
||||||
|
else if (delta < -8) setCollapsed(false);
|
||||||
|
lastScrollYRef.current = y;
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
|
}, [sticky]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 sm:px-6",
|
"px-4 sm:px-6",
|
||||||
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
|
bare ? "py-6" : "border-b border-border bg-card/85 backdrop-blur py-5",
|
||||||
sticky && "sticky top-12 sm:top-14 z-20",
|
sticky &&
|
||||||
|
"sticky top-12 sm:top-14 z-20 transition-transform duration-200 ease-out",
|
||||||
|
// -translate-y-full tucks the band fully behind the SiteHeader
|
||||||
|
// (which sits at a higher z-index), so it visually disappears.
|
||||||
|
sticky && collapsed && "-translate-y-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export function PageBookmarks() {
|
|||||||
// highlighted line.
|
// highlighted line.
|
||||||
top: m.top - 36,
|
top: m.top - 36,
|
||||||
left: m.left,
|
left: m.left,
|
||||||
zIndex: 40,
|
zIndex: 5,
|
||||||
}}
|
}}
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user