fix: ui fixes

This commit is contained in:
gosangam
2026-06-11 00:32:04 +05:30
parent 4b159cb4d9
commit ead8ad02e8
16 changed files with 769 additions and 266 deletions
+101 -10
View File
@@ -23,6 +23,8 @@ import {
Volume2,
VolumeX,
Volume1,
Pin,
PinOff,
X,
ExternalLink,
Loader2,
@@ -38,6 +40,8 @@ const MINIMIZE_DELAY = 8000;
* Returns [minimized, setMinimized, resetTimer].
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
* `resetTimer()` on any user interaction to restart the countdown.
* While `pinned`, the countdown never schedules — the player stays
* expanded until the user minimizes it manually.
*
* The `hasTrack`-driven reset of `minimized` is done via the
* "adjust state during render based on previous prop" pattern from the
@@ -46,7 +50,7 @@ const MINIMIZE_DELAY = 8000;
* fine because it fires asynchronously (the rule is about *synchronous*
* setState during the effect body).
*/
function useAutoMinimize(hasTrack: boolean) {
function useAutoMinimize(hasTrack: boolean, pinned: boolean) {
const [minimized, setMinimized] = useState(false);
const [lastHasTrack, setLastHasTrack] = useState(hasTrack);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -68,8 +72,9 @@ function useAutoMinimize(hasTrack: boolean) {
const startTimer = useCallback(() => {
clearTimer();
if (pinned) return;
timerRef.current = setTimeout(() => setMinimized(true), MINIMIZE_DELAY);
}, [clearTimer]);
}, [clearTimer, pinned]);
// Drive the timer from hasTrack. The effect body itself only calls
// start/clear (which schedule, not mutate state); the actual
@@ -106,6 +111,28 @@ const MINI_POS_STORAGE_KEY = "spsays:audio-mini-pos";
// the click-to-expand that would otherwise fire on pointer-up.
const MINI_DRAG_THRESHOLD = 4;
// Pinned state persistence — pinning prevents both auto-minimize paths
// (inactivity timer + tap-outside), and the choice survives reloads.
const PIN_STORAGE_KEY = "spsays:audio-pinned";
function loadPinned(): boolean {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(PIN_STORAGE_KEY) === "1";
} catch {
return false;
}
}
function savePinned(pinned: boolean) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(PIN_STORAGE_KEY, pinned ? "1" : "0");
} catch {
// Storage may be unavailable — the pin still holds for this session.
}
}
type MiniPos = { x: number; y: number };
function clampMiniPos(p: MiniPos): MiniPos {
@@ -424,8 +451,22 @@ export function FloatingPlayer() {
const playerRef = useRef<HTMLDivElement>(null);
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack);
const [pinned, setPinned] = useState(loadPinned);
const { minimized, setMinimized, resetTimer } = useAutoMinimize(
!!currentTrack,
pinned,
);
const togglePin = useCallback(() => {
setPinned((p) => {
const next = !p;
savePinned(next);
return next;
});
// Un-minimize + restart/clear the countdown under the new pin state
// (the timer hook re-evaluates when `pinned` flips).
resetTimer();
}, [resetTimer]);
// Minimized pill position lives in motion values so framer's `drag`
// can mutate them directly without round-tripping through React state.
@@ -496,14 +537,19 @@ export function FloatingPlayer() {
return () => observer.disconnect();
}, [currentTrack]);
// Add bottom padding to body when player is visible
// Reserve space under the page content while the FULL bar is on screen so
// it doesn't cover the end of the page. Scoped to the expanded state: the
// minimized pill floats and needs no clearance, and a track stays loaded
// (pill present) until explicitly closed — leaving this padding keyed to
// the track alone parked a permanent 96px dead band at the bottom of every
// page (visible as a gap above the bottom nav) after one use of the player.
useEffect(() => {
if (!currentTrack) return;
if (!currentTrack || minimized) return;
document.body.style.paddingBottom = "96px";
return () => {
document.body.style.paddingBottom = "";
};
}, [currentTrack]);
}, [currentTrack, minimized]);
// Wrap interactions to reset the minimize timer
const interact = useCallback(
@@ -523,9 +569,9 @@ export function FloatingPlayer() {
const iPrevious = interact(previous);
const iStop = interact(stop);
// Minimize when clicking/tapping outside the player
// Minimize when clicking/tapping outside the player — unless pinned.
useEffect(() => {
if (!currentTrack || minimized) return;
if (!currentTrack || minimized || pinned) return;
function handleOutsideClick(e: PointerEvent) {
const player = playerRef.current;
@@ -537,7 +583,7 @@ export function FloatingPlayer() {
document.addEventListener("pointerdown", handleOutsideClick);
return () =>
document.removeEventListener("pointerdown", handleOutsideClick);
}, [currentTrack, minimized, setMinimized]);
}, [currentTrack, minimized, pinned, setMinimized]);
// Keyboard shortcuts when player is focused
const handleKeyDown = useCallback(
@@ -900,6 +946,31 @@ export function FloatingPlayer() {
</Link>
)}
{/* Pin — keeps the player expanded (no auto-minimize) */}
<button
type="button"
onClick={togglePin}
aria-pressed={pinned}
aria-label={pinned ? "Unpin player" : "Pin player open"}
title={
pinned
? "Unpin — player minimizes on its own again"
: "Pin — keep the player from minimizing"
}
className={cn(
"size-7 inline-flex items-center justify-center rounded-md transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
pinned
? "text-primary bg-white/10 hover:bg-white/15"
: "text-white/50 hover:text-white hover:bg-white/10",
)}
>
{pinned ? (
<PinOff className="size-4" />
) : (
<Pin className="size-4" />
)}
</button>
{/* Minimize (playing) or Close (paused) */}
<button
type="button"
@@ -963,6 +1034,26 @@ export function FloatingPlayer() {
</Link>
)}
{/* Pin — keeps the player expanded (no auto-minimize) */}
<button
type="button"
onClick={togglePin}
aria-pressed={pinned}
aria-label={pinned ? "Unpin player" : "Pin player open"}
className={cn(
"size-8 inline-flex items-center justify-center rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0",
pinned
? "text-primary bg-white/10 hover:bg-white/15"
: "text-white/50 hover:text-white hover:bg-white/10",
)}
>
{pinned ? (
<PinOff className="size-4" />
) : (
<Pin className="size-4" />
)}
</button>
{/* Minimize (playing) or Close (paused) */}
<button
type="button"
+284
View File
@@ -0,0 +1,284 @@
"use client";
/**
* FindInPage — in-page text search for long reading pages (transcripts etc.).
*
* Renders a "Find in page" trigger for a page header; while open, a floating
* find bar with a query input, match counter, and prev/next navigation.
* Matches are highlighted by wrapping text nodes in <mark data-find> directly
* in the DOM, so it works against server-rendered content (including
* dangerouslySetInnerHTML blocks) without needing the text as props.
*
* Matching is case- AND diacritic-insensitive — "krsna" finds "Kṛṣṇa",
* "sanjaya" finds "Sañjaya" — essential for IAST transliteration content
* that users can't easily type.
*
* The bar is portaled to <body>: the sticky PageHeader animates with a
* transform when it collapses on scroll, which would hijack any
* position:fixed descendant and drag the bar off-screen with it.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
const MIN_QUERY = 2;
const DEBOUNCE_MS = 250;
const HIT_CLASS = "bg-primary/20 text-inherit rounded-[2px]";
const CURRENT_CLASS = "bg-primary text-primary-foreground rounded-[2px]";
/** Case + diacritic fold: "Kṛṣṇa" → "krsna". */
function fold(s: string): string {
return s
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
/**
* Fold a string keeping a map from each folded char back to the index of
* the original char it came from, so match offsets translate back.
*/
function foldWithMap(s: string): { folded: string; map: number[] } {
let folded = "";
const map: number[] = [];
for (let i = 0; i < s.length; i++) {
const f = fold(s[i]);
for (let j = 0; j < f.length; j++) {
folded += f[j];
map.push(i);
}
}
return { folded, map };
}
/** Unwrap every mark this component added and re-merge the text nodes. */
function clearHighlights(container: HTMLElement) {
container.querySelectorAll("mark[data-find]").forEach((mark) => {
const parent = mark.parentNode;
if (!parent) return;
parent.replaceChild(
document.createTextNode(mark.textContent ?? ""),
mark,
);
parent.normalize();
});
}
/**
* Highlight every occurrence of `query` inside `container`, returning the
* created marks in document order. Searches each text node independently
* (matches spanning inline-tag boundaries are not found — acceptable for
* prose where emphasis runs are whole words/phrases).
*/
function applyHighlights(container: HTMLElement, query: string): HTMLElement[] {
const foldedQuery = fold(query);
if (!foldedQuery) return [];
// Snapshot the nodes first — wrapping mutates the tree under the walker.
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
textNodes.push(n as Text);
}
const marks: HTMLElement[] = [];
for (const node of textNodes) {
const text = node.textContent ?? "";
if (!text.trim()) continue;
const { folded, map } = foldWithMap(text);
// Collect match ranges in original-string coordinates.
const ranges: Array<[number, number]> = [];
let from = 0;
for (;;) {
const at = folded.indexOf(foldedQuery, from);
if (at === -1) break;
const endFolded = at + foldedQuery.length;
const start = map[at];
const end = endFolded < map.length ? map[endFolded] : text.length;
if (end > start) ranges.push([start, end]);
from = endFolded;
}
if (ranges.length === 0) continue;
// Rebuild the node as text/mark fragments.
const frag = document.createDocumentFragment();
let cursor = 0;
for (const [start, end] of ranges) {
if (start > cursor) {
frag.appendChild(document.createTextNode(text.slice(cursor, start)));
}
const mark = document.createElement("mark");
mark.setAttribute("data-find", "");
mark.className = HIT_CLASS;
mark.textContent = text.slice(start, end);
frag.appendChild(mark);
marks.push(mark);
cursor = end;
}
if (cursor < text.length) {
frag.appendChild(document.createTextNode(text.slice(cursor)));
}
node.parentNode?.replaceChild(frag, node);
}
return marks;
}
/** Style the active mark and bring it into view. */
function focusMark(marks: HTMLElement[], index: number) {
marks.forEach((m, i) => {
m.className = i === index ? CURRENT_CLASS : HIT_CLASS;
});
marks[index]?.scrollIntoView({ block: "center", behavior: "smooth" });
}
export function FindInPage({
targetId,
className,
}: {
/** id of the element whose text content is searched. */
targetId: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [count, setCount] = useState(0);
const [current, setCurrent] = useState(0);
const marksRef = useRef<HTMLElement[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const close = useCallback(() => {
const el = document.getElementById(targetId);
if (el) clearHighlights(el);
marksRef.current = [];
setOpen(false);
setQuery("");
setCount(0);
setCurrent(0);
}, [targetId]);
// Debounced (re)search on every query change while open.
useEffect(() => {
if (!open) return;
const id = window.setTimeout(() => {
const el = document.getElementById(targetId);
if (!el) return;
clearHighlights(el);
const q = query.trim();
const marks = q.length >= MIN_QUERY ? applyHighlights(el, q) : [];
marksRef.current = marks;
setCount(marks.length);
setCurrent(0);
if (marks.length > 0) focusMark(marks, 0);
}, DEBOUNCE_MS);
return () => window.clearTimeout(id);
}, [query, open, targetId]);
// Focus the input when the bar opens.
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
// Drop any leftover highlights if the component unmounts mid-search.
useEffect(() => {
return () => {
const el = document.getElementById(targetId);
if (el) clearHighlights(el);
};
}, [targetId]);
const step = useCallback(
(dir: 1 | -1) => {
const marks = marksRef.current;
if (marks.length === 0) return;
setCurrent((c) => {
const next = (c + dir + marks.length) % marks.length;
focusMark(marks, next);
return next;
});
},
[],
);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Find in page"
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors",
className,
)}
>
<Search className="size-4" />
<span className="hidden sm:inline">Find in page</span>
</button>
{open &&
createPortal(
<div className="fixed top-14 sm:top-16 inset-x-0 z-30 flex justify-center px-4 pointer-events-none">
<div className="pointer-events-auto flex w-full max-w-md items-center gap-1 rounded-xl border border-border bg-card px-2 py-1.5 shadow-lg">
<Search className="ml-1 size-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
step(e.shiftKey ? -1 : 1);
} else if (e.key === "Escape") {
e.preventDefault();
close();
}
}}
placeholder="Find in page…"
aria-label="Find in page"
className="min-w-0 flex-1 bg-transparent py-1 text-sm outline-none placeholder:text-muted-foreground"
/>
<span
aria-live="polite"
className="shrink-0 select-none text-xs tabular-nums text-muted-foreground"
>
{count > 0
? `${current + 1}/${count}`
: query.trim().length >= MIN_QUERY
? "0 results"
: ""}
</span>
<button
type="button"
onClick={() => step(-1)}
disabled={count === 0}
aria-label="Previous match"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronUp className="size-4" />
</button>
<button
type="button"
onClick={() => step(1)}
disabled={count === 0}
aria-label="Next match"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronDown className="size-4" />
</button>
<button
type="button"
onClick={close}
aria-label="Close find bar"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="size-4" />
</button>
</div>
</div>,
document.body,
)}
</>
);
}
@@ -1,83 +0,0 @@
"use client";
/**
* LikeEmailToggle — settings control for aggregated "someone liked your post"
* emails. Reads the current preference and lets the user opt out (push +
* in-app like notifications are unaffected). Matches the other settings rows.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { notifications } from "@/lib/social";
export function LikeEmailToggle() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
notifications
.emailPrefs()
.then((r) => {
if (!cancelled) setEnabled(r.like_emails);
})
.catch(() => {
if (!cancelled) setEnabled(true); // default-on; toggle still works
});
return () => {
cancelled = true;
};
}, []);
async function toggle() {
if (enabled === null) return;
const next = !enabled;
setBusy(true);
try {
await notifications.setEmailPrefs(next);
setEnabled(next);
toast.success(next ? "Like emails turned on" : "Like emails turned off");
} catch {
toast.error("Could not update preference");
} finally {
setBusy(false);
}
}
return (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">Like emails</span>
<p className="text-xs text-muted-foreground mt-0.5">
Get an email when someone likes your post (bundled — one email per
post). Push and in-app notifications stay on either way.
</p>
</div>
<div className="shrink-0">
{enabled === null ? (
<span className="text-xs text-muted-foreground">…</span>
) : (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={enabled}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
enabled ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
)}
</div>
</li>
</ul>
);
}
@@ -0,0 +1,156 @@
"use client";
/**
* NotificationMatrix — the Settings grid controlling which events reach the
* user over which channels (in-app, email, push). Account-wide preferences;
* the device-level PushToggle above it governs whether *this* device can
* receive push at all (two layers, like an OS permission vs. app settings).
*
* Cells write through to PUT /notifications/prefs one at a time and re-render
* from the matrix the server returns, so the UI always shows resolved state.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
notifications,
type NotificationPrefs,
type PrefChannel,
type PrefEvent,
} from "@/lib/social";
import { ToggleSwitch } from "@/components/settings/setting-row";
import {
enablePush,
isPushEnabled,
pushPermission,
pushSupported,
} from "@/lib/push";
const EVENTS: { key: PrefEvent; label: string; description: string }[] = [
{
key: "follows",
label: "Follows",
description: "Follow requests and accepted requests.",
},
{
key: "post_interactions",
label: "Post interactions",
description: "Likes and mentions on your posts.",
},
{
key: "sadhna_reminders",
label: "Sādhana reminders",
description: "Daily nudges to chant your rounds and share a verse.",
},
];
const CHANNELS: { key: PrefChannel; label: string }[] = [
{ key: "in_app", label: "In-app" },
{ key: "email", label: "Email" },
{ key: "push", label: "Push" },
];
export function NotificationMatrix() {
const [prefs, setPrefs] = useState<NotificationPrefs | null>(null);
const [failed, setFailed] = useState(false);
// The "event/channel" cell currently being written, so only it disables.
const [busyCell, setBusyCell] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
notifications
.prefs()
.then(({ prefs }) => {
if (!cancelled) setPrefs(prefs);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, []);
async function toggle(event: PrefEvent, channel: PrefChannel) {
if (!prefs) return;
const next = !prefs[event][channel];
setBusyCell(`${event}/${channel}`);
try {
// Turning a push cell ON only helps if this device can receive push —
// offer the permission/subscription right at the gesture, best-effort
// (the account-wide pref still saves if the user declines).
if (
channel === "push" &&
next &&
pushSupported() &&
pushPermission() !== "denied" &&
!(await isPushEnabled())
) {
await enablePush().catch(() => {
/* declined / unavailable — other subscribed devices still get it */
});
}
const { prefs: updated } = await notifications.setPref(
event,
channel,
next,
);
setPrefs(updated);
} catch {
toast.error("Could not update preference");
} finally {
setBusyCell(null);
}
}
if (failed) {
return (
<p className="py-3 text-xs text-muted-foreground">
Couldn&apos;t load your notification preferences. Reload to try again.
</p>
);
}
return (
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-x-4 gap-y-0 sm:gap-x-6">
{/* Header row */}
<span aria-hidden />
{CHANNELS.map((c) => (
<span
key={c.key}
className="pb-2 text-center text-xs font-medium text-muted-foreground"
>
{c.label}
</span>
))}
{EVENTS.map((event) => (
<div key={event.key} className="contents">
<div className="min-w-0 border-t border-border py-3">
<span className="text-sm text-foreground">{event.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">
{event.description}
</p>
</div>
{CHANNELS.map((channel) => (
<div
key={channel.key}
className="flex justify-center border-t border-border py-3"
>
{prefs ? (
<ToggleSwitch
checked={prefs[event.key][channel.key]}
disabled={busyCell === `${event.key}/${channel.key}`}
onToggle={() => toggle(event.key, channel.key)}
label={`${event.label} — ${channel.label}`}
/>
) : (
<span className="text-xs text-muted-foreground">…</span>
)}
</div>
))}
</div>
))}
</div>
);
}
+22 -40
View File
@@ -9,6 +9,7 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { SettingRow, ToggleSwitch } from "@/components/settings/setting-row";
import {
disablePush,
enablePush,
@@ -73,48 +74,29 @@ export function PushToggle() {
}
}
const description = describe(state);
return (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">
Push notifications
<SettingRow
label="Push notifications"
description={describe(state)}
control={
state === "on" || state === "off" ? (
<ToggleSwitch
checked={state === "on"}
disabled={busy}
onToggle={toggle}
label="Push notifications"
/>
) : (
<span className="text-xs text-muted-foreground">
{state === "loading"
? "…"
: state === "ios-install"
? null
: "Unavailable"}
</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<div className="shrink-0">
{state === "on" || state === "off" ? (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={state === "on"}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
state === "on" ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
state === "on" ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
) : (
<span className="text-xs text-muted-foreground">
{state === "loading"
? "…"
: state === "ios-install"
? null
: "Unavailable"}
</span>
)}
</div>
</li>
</ul>
)
}
/>
);
}
-104
View File
@@ -1,104 +0,0 @@
"use client";
/**
* ReminderToggle — the settings control for daily sādhana reminders (email +
* push + websocket). Reads the current preference and lets the user opt out.
* Renders as a settings row matching the rest of /settings.
*/
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { sadhna } from "@/lib/sadhna";
import {
enablePush,
isPushEnabled,
pushPermission,
pushSupported,
} from "@/lib/push";
export function ReminderToggle() {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
sadhna
.reminderPrefs()
.then((r) => {
if (!cancelled) setEnabled(r.enabled);
})
.catch(() => {
if (!cancelled) setEnabled(true); // default-on; toggle still works
});
return () => {
cancelled = true;
};
}, []);
async function toggle() {
if (enabled === null) return;
const next = !enabled;
setBusy(true);
try {
// Turning reminders ON also offers push permission on this device, so
// the reminder can actually reach a closed app. Best-effort: if the
// user declines or push isn't available, reminders still go by email
// and in-app. Done before saving the pref so the permission request
// stays close to the toggle gesture.
if (
next &&
pushSupported() &&
pushPermission() !== "denied" &&
!(await isPushEnabled())
) {
await enablePush().catch(() => {
/* declined / unavailable — reminders still work elsewhere */
});
}
await sadhna.setReminderPrefs(next);
setEnabled(next);
toast.success(next ? "Reminders turned on" : "Reminders turned off");
} catch {
toast.error("Could not update reminders");
} finally {
setBusy(false);
}
}
return (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">Sādhana reminders</span>
<p className="text-xs text-muted-foreground mt-0.5">
Daily nudges to chant your rounds and share a verse — by email,
push, and in-app.
</p>
</div>
<div className="shrink-0">
{enabled === null ? (
<span className="text-xs text-muted-foreground">…</span>
) : (
<button
type="button"
onClick={toggle}
disabled={busy}
aria-pressed={enabled}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
enabled ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
enabled ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
)}
</div>
</li>
</ul>
);
}
@@ -37,12 +37,17 @@ type Status = "idle" | "saving" | "saved" | "auth" | "limit" | "error";
export function CollectionsDrawer({
open,
onClose,
onSaved,
isMobile,
selectedText,
existingPostId,
}: {
open: boolean;
onClose: () => void;
/** Called instead of onClose after a successful save. Lets the selection
* toolbar end the flow (drop the selection) rather than restore it the
* way a cancel/dismiss does. Falls back to onClose when omitted. */
onSaved?: () => void;
isMobile: boolean;
/** Provided by the selection toolbar — the drawer will create a fresh
* post from this text before adding it to a collection. */
@@ -150,7 +155,9 @@ export function CollectionsDrawer({
c.id === collectionID ? { ...c, item_count: c.item_count + 1 } : c,
),
);
setTimeout(onClose, 800);
// Leave the "Saved ✓" row state visible for a beat, then complete the
// flow — onSaved (don't restore the selection) when provided.
setTimeout(onSaved ?? onClose, 800);
} catch (err) {
if (err instanceof SocialError) {
const tone: Status =
+65
View File
@@ -0,0 +1,65 @@
"use client";
/**
* Shared building blocks for /settings rows: SettingRow lays out a
* label + description with a control on the right, and ToggleSwitch is the
* pill switch every boolean preference uses. Keeps the notification toggles
* (push, reminders, like emails) from each carrying a copy of this markup.
*/
export function SettingRow({
label,
description,
control,
}: {
label: string;
description: React.ReactNode;
control: React.ReactNode;
}) {
return (
<ul className="divide-y divide-border">
<li className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<div className="shrink-0">{control}</div>
</li>
</ul>
);
}
export function ToggleSwitch({
checked,
disabled,
onToggle,
label,
}: {
checked: boolean;
disabled?: boolean;
onToggle: () => void;
/** Accessible name — the row label isn't programmatically associated. */
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={onToggle}
disabled={disabled}
className={[
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-60",
checked ? "bg-primary" : "bg-muted-foreground/30",
].join(" ")}
>
<span
className={[
"inline-block size-5 transform rounded-full bg-white shadow transition-transform",
checked ? "translate-x-5" : "translate-x-0.5",
].join(" ")}
/>
</button>
);
}
+25 -5
View File
@@ -2,7 +2,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
AtSign,
Bookmark as BookmarkIcon,
Copy,
FolderPlus,
@@ -113,6 +112,18 @@ export function TextSelectionToolbar() {
setShowMore(false);
}, []);
// Completed-action teardown. Unlike clearSelection (which only hides the
// toolbar UI), this also drops the selection in the document — otherwise
// the still-highlighted text re-triggers the toolbar on the very next
// selectionchange/touchend — and forgets the saved range so a drawer's
// onClose can't restore it after the action already succeeded.
const finishAction = useCallback(() => {
savedRangeRef.current = null;
interactingRef.current = false;
window.getSelection()?.removeAllRanges();
clearSelection();
}, [clearSelection]);
// Listen for selection changes
useEffect(() => {
function handleSelectionChange() {
@@ -349,7 +360,7 @@ export function TextSelectionToolbar() {
});
toast.success("Bookmark saved", { duration: 2000 });
window.dispatchEvent(new CustomEvent("spsays:bookmarks-changed"));
clearSelection();
finishAction();
} catch (err) {
if (err instanceof SocialError && err.status === 402) {
toast.error("Bookmark limit reached", {
@@ -369,14 +380,14 @@ export function TextSelectionToolbar() {
async function onCopyText() {
await copy(formatCopyText(selectedText));
toast.success("Copied to clipboard", { duration: 1500 });
clearSelection();
finishAction();
}
async function onCopyLink() {
const url = await buildShortHighlightUrl(selectedText);
await copy(url);
toast.success("Link copied", { duration: 1500 });
clearSelection();
finishAction();
}
async function onSaveToCollection() {
@@ -400,7 +411,7 @@ export function TextSelectionToolbar() {
icon: <KautilyaIcon className="size-3.5" />,
onClick: () => {
// TODO: Implement Ask Kautilya
clearSelection();
finishAction();
},
className: "text-amber-400 hover:bg-amber-400/15 hover:text-amber-300",
};
@@ -549,6 +560,9 @@ export function TextSelectionToolbar() {
<ComposePost
open={showCompose}
// Successful post → end the flow; the onClose that follows finds
// no saved range left to restore, so the toolbar stays gone.
onPosted={finishAction}
onClose={() => {
setShowCompose(false);
if (isMobile) restoreSelection();
@@ -560,6 +574,12 @@ export function TextSelectionToolbar() {
<CollectionsDrawer
open={showCollections}
// Successful save → close the drawer and end the flow (cancel still
// goes through onClose and restores the selection).
onSaved={() => {
setShowCollections(false);
finishAction();
}}
onClose={() => {
setShowCollections(false);
if (isMobile) restoreSelection();
+8
View File
@@ -7,10 +7,18 @@ import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
// On iOS vaul freezes <body> (position:fixed) while the drawer is open and
// restores the old scroll offset on close. When a link inside the drawer
// navigates, that restore fires AFTER the route change and drags the new
// page to the previous page's scroll position — from the bottom of a long
// page it looks like the link simply didn't work. This flag makes vaul
// skip the restore when the URL changed; plain dismissals still restore.
preventScrollRestoration = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
preventScrollRestoration={preventScrollRestoration}
{...props}
/>
);