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 { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -389,22 +388,17 @@ export function GlossaryClient({
|
||||
<>
|
||||
{/* Sticky header */}
|
||||
<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="flex-1 min-w-0">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">Glossary: </span>
|
||||
{activeEntry?.term || activeLetter}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="shrink-0 pt-1">
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Home
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">Glossary: </span>
|
||||
{activeEntry?.term || activeLetter}
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ export default function RootLayout({
|
||||
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
|
||||
<SiteHeader />
|
||||
{children}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
<SiteBottomNav />
|
||||
<TextSelectionToolbar />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
|
||||
import {
|
||||
@@ -451,29 +450,24 @@ export function FilterYearBrowser({
|
||||
<>
|
||||
{/* Header */}
|
||||
<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="flex-1 min-w-0">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← {backLabel}
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">
|
||||
{titlePrefix} {filterName}:{" "}
|
||||
</span>
|
||||
{activeYearLabel}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{activeGroup?.count ?? 0}{" "}
|
||||
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} in{" "}
|
||||
{activeYearLabel} · {totalCount} total
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 pt-1">
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← {backLabel}
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">
|
||||
{titlePrefix} {filterName}:{" "}
|
||||
</span>
|
||||
{activeYearLabel}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{activeGroup?.count ?? 0}{" "}
|
||||
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} in{" "}
|
||||
{activeYearLabel} · {totalCount} total
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight, Menu } from "lucide-react";
|
||||
import {
|
||||
@@ -356,27 +355,22 @@ export function YearMonthBrowser({
|
||||
<>
|
||||
{/* Header */}
|
||||
<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="flex-1 min-w-0">
|
||||
<Link
|
||||
href="/letters?view=year"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Letters by Year
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">Letters: </span>
|
||||
{activeLabel} {year}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{activeGroup?.count ?? 0}{" "}
|
||||
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "}
|
||||
{totalCount} total in {year}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 pt-1">
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Link
|
||||
href="/letters?view=year"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Letters by Year
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">Letters: </span>
|
||||
{activeLabel} {year}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{activeGroup?.count ?? 0}{" "}
|
||||
{(activeGroup?.count ?? 0) === 1 ? "letter" : "letters"} ·{" "}
|
||||
{totalCount} total in {year}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ export default async function TranscriptionsPage({
|
||||
Transcriptions
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{totalCount.toLocaleString()} lectures & conversations of Śrīla
|
||||
Prabhupāda (1966–1977)
|
||||
{totalCount.toLocaleString()} lectures & conversations of
|
||||
Śrīla Prabhupāda (1966–1977)
|
||||
</p>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { ReadingSettings } from "@/components/reading-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronLeft, ChevronRight, Menu, Clock, MapPin } from "lucide-react";
|
||||
import { PlayAudioButton } from "@/app/transcriptions/[slug]/play-audio-button";
|
||||
@@ -477,29 +476,24 @@ export function TranscriptionTypeBrowser({
|
||||
<>
|
||||
{/* Header */}
|
||||
<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="flex-1 min-w-0">
|
||||
<Link
|
||||
href="/transcriptions?view=type"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Transcriptions by Type
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">{typeLabel}: </span>
|
||||
{activeYearLabel}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{activeGroup?.count ?? 0}{" "}
|
||||
{(activeGroup?.count ?? 0) === 1
|
||||
? "transcription"
|
||||
: "transcriptions"}{" "}
|
||||
in {activeYearLabel} · {totalCount} total
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 pt-1">
|
||||
<ReadingSettings />
|
||||
</div>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Link
|
||||
href="/transcriptions?view=type"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
← Transcriptions by Type
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-foreground">
|
||||
<span className="text-primary">{typeLabel}: </span>
|
||||
{activeYearLabel}
|
||||
</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{activeGroup?.count ?? 0}{" "}
|
||||
{(activeGroup?.count ?? 0) === 1
|
||||
? "transcription"
|
||||
: "transcriptions"}{" "}
|
||||
in {activeYearLabel} · {totalCount} total
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useCallback, useEffect, useState } from "react";
|
||||
import { useRef, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAudioPlayer } from "./audio-context";
|
||||
import { AudioWaveAnimation } from "./audio-wave";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { motion, AnimatePresence, useMotionValue } from "framer-motion";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
@@ -84,6 +84,59 @@ function useAutoMinimize(hasTrack: boolean) {
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -295,6 +348,57 @@ export function FloatingPlayer() {
|
||||
const { minimized, setMinimized, resetTimer } =
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!currentTrack) return;
|
||||
@@ -422,24 +526,64 @@ export function FloatingPlayer() {
|
||||
data-audio-player
|
||||
type="button"
|
||||
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);
|
||||
resetTimer();
|
||||
}}
|
||||
aria-label="Expand audio player"
|
||||
initial={{ opacity: 0, scale: 0.5, bottom: -60 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
bottom: selectionToolbarVisible ? 90 : 24,
|
||||
drag
|
||||
dragMomentum={false}
|
||||
dragElastic={0}
|
||||
dragConstraints={miniBounds}
|
||||
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 }}
|
||||
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(
|
||||
"fixed z-40 right-4",
|
||||
"z-40",
|
||||
"size-14 rounded-full",
|
||||
"shadow-2xl shadow-black/30",
|
||||
"overflow-hidden",
|
||||
"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 */}
|
||||
|
||||
@@ -14,11 +14,16 @@
|
||||
* hidden and replaced with a "← Back to search results" button. This
|
||||
* 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
|
||||
* 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 { cn } from "@/lib/utils";
|
||||
|
||||
@@ -63,12 +68,39 @@ export function PageHeader({
|
||||
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 (
|
||||
<header
|
||||
className={cn(
|
||||
"px-4 sm:px-6",
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -193,7 +193,7 @@ export function PageBookmarks() {
|
||||
// highlighted line.
|
||||
top: m.top - 36,
|
||||
left: m.left,
|
||||
zIndex: 40,
|
||||
zIndex: 5,
|
||||
}}
|
||||
className="pointer-events-none"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user