-
+ >
+ {done ? : icon}
+
+
+
{title}
-
{subtitle}
+
{subtitle}
);
}
-// ─── Vine panel ───────────────────────────────────────────────────────
+// ─── Copy ──────────────────────────────────────────────────────────────
-function VinePanel({
- dash,
- error,
-}: {
- dash: SadhnaDashboard | null;
- error: boolean;
-}) {
- return (
-
- {error ? (
-
-
- Could not load your sādhana. Please try again.
-
-
- ) : dash ? (
-
- ) : (
-
- )}
-
- );
-}
-
-// ─── Copy + helpers ────────────────────────────────────────────────────
-
-function vineCaption(dash: SadhnaDashboard | null): string {
- if (!dash) return "Two simple offerings each day. Keep them up and watch it climb.";
- if (dash.current_streak === 0 && !dash.alive) {
- return "Begin today, and a new vine starts climbing the pole.";
- }
- if (dash.today_completed) {
- return "Today's fold is complete. The vine climbs higher.";
- }
+function caption(dash: SadhnaDashboard | null): string {
+ if (!dash) return "Two simple offerings each day — a lotus for every one.";
+ if (dash.today_completed) return "Today's sādhana is complete — a lotus blooms.";
if (dash.chant_done || dash.quote_done) {
- return "One offering done — finish the other to complete today's fold.";
+ return "One offering done. Finish the other to complete today.";
}
- return "Each day's sādhana winds another fold around the pole.";
-}
-
-/** Single-letter weekday (UTC, matching the backend's day buckets). */
-function weekdayLetter(date: string): string {
- const d = new Date(`${date}T00:00:00Z`);
- if (Number.isNaN(d.getTime())) return "";
- return ["S", "M", "T", "W", "T", "F", "S"][d.getUTCDay()];
-}
-
-/** A blank 7-day strip for the initial render before data arrives. */
-function placeholderWeek(): SadhnaDay[] {
- const out: SadhnaDay[] = [];
- const now = new Date();
- const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
- for (let i = 6; i >= 0; i--) {
- const d = new Date(today - i * 86400000);
- out.push({
- date: d.toISOString().slice(0, 10),
- chant_done: false,
- quote_done: false,
- completed: false,
- });
- }
- return out;
+ return "Chant your rounds and post a quote to complete today.";
}
diff --git a/app/(app)/sadhna-streak/preview/page.tsx b/app/(app)/sadhna-streak/preview/page.tsx
deleted file mode 100644
index 4fc098c3..00000000
--- a/app/(app)/sadhna-streak/preview/page.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-"use client";
-
-/**
- * /sadhna-streak/preview — DEV-ONLY sandbox for the sādhana creeper.
- *
- * Lets you fast-track the vine: scrub the streak length, set today's
- * in-progress rounds/posts, and dial the health (growth) score to watch the
- * vine climb the pole, sprout folds + blossoms, and wither — all without
- * seeding real logs. Returns nothing in production builds.
- *
- * The synthesized payload matches what the BFF's Dashboard builds, so what
- * you see here mirrors live.
- */
-
-import { useState } from "react";
-import { SadhnaVine } from "@/components/sadhna/sadhna-vine";
-import type { VineDay } from "@/lib/sadhna";
-import { cn } from "@/lib/utils";
-
-export default function SadhnaPreviewPage() {
- if (process.env.NODE_ENV === "production") {
- return (
-
- Not available.
-
- );
- }
- return
;
-}
-
-/** Build a vine_days payload exactly the way the BFF would. */
-function buildDays(
- completed: number,
- target: number,
- todayRounds: number,
- todayPosts: number,
- todayComplete: boolean,
-): VineDay[] {
- const out: VineDay[] = [];
- const now = new Date();
- const todayMs = Date.UTC(
- now.getUTCFullYear(),
- now.getUTCMonth(),
- now.getUTCDate(),
- );
- const endOffset = todayComplete ? 0 : 1; // days-ago of the last finished fold
- for (let i = completed - 1; i >= 0; i--) {
- const daysAgo = endOffset + i;
- const date = new Date(todayMs - daysAgo * 86400000)
- .toISOString()
- .slice(0, 10);
- out.push({
- date,
- rounds: target + (i % 3),
- posts: 1 + (i % 2),
- completed: true,
- in_progress: false,
- });
- }
- if (!todayComplete) {
- out.push({
- date: new Date(todayMs).toISOString().slice(0, 10),
- rounds: todayRounds,
- posts: todayPosts,
- completed: false,
- in_progress: true,
- });
- }
- return out;
-}
-
-function Preview() {
- const [completed, setCompleted] = useState(3);
- const [target] = useState(16);
- const [todayComplete, setTodayComplete] = useState(false);
- const [todayRounds, setTodayRounds] = useState(8);
- const [todayPosts, setTodayPosts] = useState(0);
- const [growth, setGrowth] = useState(60);
-
- const days = buildDays(completed, target, todayRounds, todayPosts, todayComplete);
- const streak = completed; // current streak = finished folds
- const alive = growth > 0;
-
- return (
-
-
-
- Dev
-
-
- Vine preview
-
-
-
- {/* Controls */}
-
-
-
-
-
-
- setTodayComplete(e.target.checked)}
- className="size-4 accent-primary"
- />
- Today already complete
-
-
- setGrowth(Math.min(100, completed * 4))}>
- sync health → streak
-
- setGrowth(0)}>kill
-
-
-
- {/* Live vine — same panel chrome as the real page */}
-
-
- );
-}
-
-function Slider({
- label,
- value,
- min,
- max,
- onChange,
- disabled,
-}: {
- label: string;
- value: number;
- min: number;
- max: number;
- onChange: (v: number) => void;
- disabled?: boolean;
-}) {
- return (
-
-
- {label}
- {value}
-
-
onChange(Number(e.target.value))}
- className="w-full accent-primary"
- />
-
- );
-}
-
-function Chip({
- children,
- onClick,
-}: {
- children: React.ReactNode;
- onClick: () => void;
-}) {
- return (
-
- {children}
-
- );
-}
diff --git a/app/(app)/search/filters-menu.tsx b/app/(app)/search/filters-menu.tsx
index ea36ad31..026dd960 100644
--- a/app/(app)/search/filters-menu.tsx
+++ b/app/(app)/search/filters-menu.tsx
@@ -66,12 +66,10 @@ export function SearchFiltersMenu({
// active count is one OR the other — never their sum.
const activeCount = isUserChipActive ? 1 : selectedTypes.size;
- // Desktop popover positioning — below the trigger, left-aligned.
+ // Desktop popover positioning — below the trigger, left-aligned. (Stale
+ // pos while closed is harmless — the popover only renders when open.)
useEffect(() => {
- if (!open || isMobile) {
- setPos(null);
- return;
- }
+ if (!open || isMobile) return;
function recompute() {
const btn = triggerRef.current;
if (!btn) return;
diff --git a/app/(app)/search/search-content.tsx b/app/(app)/search/search-content.tsx
index fda4b7c0..bacbd4d3 100644
--- a/app/(app)/search/search-content.tsx
+++ b/app/(app)/search/search-content.tsx
@@ -283,6 +283,12 @@ function SearchInner() {
// Fetch search results — branches between user search (`@`-prefixed)
// and content search. We clear the *other* state slot on every run
// so the UI doesn't show stale results from a previous mode.
+ //
+ // This effect synchronizes results with the debounced query; the
+ // synchronous "show loading + clear stale" updates are intentional (they
+ // prevent flashing previous results), so set-state-in-effect is disabled
+ // for this block.
+ /* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const query = debouncedQuery.trim();
@@ -363,6 +369,7 @@ function SearchInner() {
isUserQueryEmpty,
userQuery,
]);
+ /* eslint-enable react-hooks/set-state-in-effect */
// Auto-focus the input on mount
useEffect(() => {
@@ -404,11 +411,6 @@ function SearchInner() {
}
};
- const isTypeActive = (type: string): boolean => {
- if (type === "all") return selectedTypes.size === 0;
- return selectedTypes.has(type);
- };
-
const handlePageChange = (page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: "smooth" });
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx
index 7375fac7..63b6e5fe 100644
--- a/app/(app)/settings/page.tsx
+++ b/app/(app)/settings/page.tsx
@@ -29,6 +29,8 @@ import { useAuth } from "@/components/auth/auth-provider";
import { AddEmailModal } from "@/components/auth/add-email-modal";
import { ChangePasswordModal } from "@/components/auth/change-password-modal";
import { ReadingSettings } from "@/components/reading-settings";
+import { PushToggle } from "@/components/push/push-toggle";
+import { ReminderToggle } from "@/components/sadhna/reminder-toggle";
const BFF_BASE =
process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
@@ -56,6 +58,14 @@ export default function SettingsPage() {
+
+
{/* Suspense boundary required because LinkResult reads
useSearchParams — Next.js bails out of SSR otherwise. */}
diff --git a/app/(app)/transcriptions/[slug]/play-audio-button.tsx b/app/(app)/transcriptions/[slug]/play-audio-button.tsx
index 73662407..dbeb7abb 100644
--- a/app/(app)/transcriptions/[slug]/play-audio-button.tsx
+++ b/app/(app)/transcriptions/[slug]/play-audio-button.tsx
@@ -6,7 +6,7 @@ import {
AudioWaveAnimation,
} from "@/components/audio-player";
import { cn } from "@/lib/utils";
-import { Play, Pause } from "lucide-react";
+import { Play } from "lucide-react";
type PlayAudioButtonProps = {
audioUrl: string;
diff --git a/app/(app)/transcriptions/place/[place]/page.tsx b/app/(app)/transcriptions/place/[place]/page.tsx
index 93136e37..58d742c8 100644
--- a/app/(app)/transcriptions/place/[place]/page.tsx
+++ b/app/(app)/transcriptions/place/[place]/page.tsx
@@ -8,7 +8,7 @@ import { buildOgMeta } from "@/lib/og";
import { PageHeader } from "@/components/page-header";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
-import { Clock, MapPin } from "lucide-react";
+import { Clock } from "lucide-react";
import { PlayAudioButton } from "@/app/(app)/transcriptions/[slug]/play-audio-button";
export async function generateMetadata({
diff --git a/app/(app)/transcriptions/type/[type]/type-browser.tsx b/app/(app)/transcriptions/type/[type]/type-browser.tsx
index 08aef431..44b71221 100644
--- a/app/(app)/transcriptions/type/[type]/type-browser.tsx
+++ b/app/(app)/transcriptions/type/[type]/type-browser.tsx
@@ -422,7 +422,6 @@ function MonthSection({
export function TranscriptionTypeBrowser({
transcriptions,
- type,
typeLabel,
totalCount,
}: {
diff --git a/app/(app)/transcriptions/year/[year]/year-browser.tsx b/app/(app)/transcriptions/year/[year]/year-browser.tsx
index efbdcf14..8962e60a 100644
--- a/app/(app)/transcriptions/year/[year]/year-browser.tsx
+++ b/app/(app)/transcriptions/year/[year]/year-browser.tsx
@@ -42,7 +42,6 @@ const MONTH_NAMES = [
];
function MonthSection({
- month,
label,
items,
defaultOpen,
diff --git a/app/(app)/u/[handle]/posts-grid.tsx b/app/(app)/u/[handle]/posts-grid.tsx
index 5af04399..fd6255f5 100644
--- a/app/(app)/u/[handle]/posts-grid.tsx
+++ b/app/(app)/u/[handle]/posts-grid.tsx
@@ -29,7 +29,6 @@ export function PostsGrid({
useEffect(() => {
let cancelled = false;
- setLoading(true);
profile
.feed(handle, 30)
.then((rows) => {
diff --git a/app/(app)/u/[handle]/posts/scroll-feed.tsx b/app/(app)/u/[handle]/posts/scroll-feed.tsx
index b6791f50..42b507c9 100644
--- a/app/(app)/u/[handle]/posts/scroll-feed.tsx
+++ b/app/(app)/u/[handle]/posts/scroll-feed.tsx
@@ -32,7 +32,6 @@ export function ScrollFeed({
useEffect(() => {
let cancelled = false;
- setLoading(true);
profile
.feed(handle, 60)
.then((rows) => {
diff --git a/app/(app)/u/me/bookmarks/page.tsx b/app/(app)/u/me/bookmarks/page.tsx
index de5b1627..0dec095b 100644
--- a/app/(app)/u/me/bookmarks/page.tsx
+++ b/app/(app)/u/me/bookmarks/page.tsx
@@ -18,7 +18,6 @@ export default function MyBookmarksPage() {
const [error, setError] = useState(null);
useEffect(() => {
- setLoading(true);
bookmarks
.list()
.then((r) => setList(r ?? []))
diff --git a/app/(app)/u/me/collections/[id]/page.tsx b/app/(app)/u/me/collections/[id]/page.tsx
index 04e4a45d..5d1c96e2 100644
--- a/app/(app)/u/me/collections/[id]/page.tsx
+++ b/app/(app)/u/me/collections/[id]/page.tsx
@@ -35,7 +35,6 @@ export default function CollectionDetailPage(props: {
useEffect(() => {
let cancelled = false;
- setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
diff --git a/app/(app)/u/me/page.tsx b/app/(app)/u/me/page.tsx
index 7a8f0334..92904eb1 100644
--- a/app/(app)/u/me/page.tsx
+++ b/app/(app)/u/me/page.tsx
@@ -51,7 +51,6 @@ function MyProfilePageInner() {
useEffect(() => {
if (!user) return;
- setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
diff --git a/app/(app)/u/me/posts/page.tsx b/app/(app)/u/me/posts/page.tsx
index 2179ce4c..fe5b6ef3 100644
--- a/app/(app)/u/me/posts/page.tsx
+++ b/app/(app)/u/me/posts/page.tsx
@@ -26,7 +26,6 @@ export default function MyPostsFeedPage() {
useEffect(() => {
if (!user) return;
- setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
diff --git a/app/(app)/verify-email/page.tsx b/app/(app)/verify-email/page.tsx
index 3ac92cdd..cdcb19da 100644
--- a/app/(app)/verify-email/page.tsx
+++ b/app/(app)/verify-email/page.tsx
@@ -17,15 +17,17 @@ function VerifyEmailContent() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const { refresh } = useAuth();
- const [status, setStatus] = useState<"pending" | "ok" | "error">("pending");
- const [message, setMessage] = useState("");
+ // Seed straight from the token's presence so the missing-token case isn't
+ // a synchronous setState inside the effect below.
+ const [status, setStatus] = useState<"pending" | "ok" | "error">(
+ token ? "pending" : "error",
+ );
+ const [message, setMessage] = useState(
+ token ? "" : "Verification link is missing a token.",
+ );
useEffect(() => {
- if (!token) {
- setStatus("error");
- setMessage("Verification link is missing a token.");
- return;
- }
+ if (!token) return;
void (async () => {
try {
await auth.verifyEmail(token);
diff --git a/app/api/og/post/[id]/route.tsx b/app/api/og/post/[id]/route.tsx
index 96d9321e..6fbae81e 100644
--- a/app/api/og/post/[id]/route.tsx
+++ b/app/api/og/post/[id]/route.tsx
@@ -1,3 +1,5 @@
+/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
+ renders raw ; next/image doesn't work inside OG image generation. */
/**
* OG card for a single post.
*
@@ -107,6 +109,7 @@ export async function GET(
{hasImage ? (
<>
{post.author_avatar_url ? (
; next/image doesn't work inside OG image generation. */
/**
* OG card for a user profile (`/u/`).
*
@@ -109,6 +111,7 @@ export async function GET(
>
{profile.avatar_url ? (
; next/image doesn't work inside OG image generation. */
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@@ -79,6 +81,7 @@ export async function GET(request: Request) {
>
{/* Prabhupāda image — already contains quote mark and branding */}
void): () => void {
- const mq = window.matchMedia(MOBILE_QUERY);
- mq.addEventListener("change", callback);
- return () => mq.removeEventListener("change", callback);
-}
-
-function getMobileSnapshot(): boolean {
- return window.matchMedia(MOBILE_QUERY).matches;
-}
-
-function getMobileServerSnapshot(): boolean {
- return false;
-}
-
-function useIsMobile() {
- return useSyncExternalStore(
- subscribeMobile,
- getMobileSnapshot,
- getMobileServerSnapshot,
- );
-}
-
/**
* Returns [minimized, setMinimized, resetTimer].
* Auto-minimizes after `MINIMIZE_DELAY` ms of inactivity. Call
@@ -460,7 +424,6 @@ export function FloatingPlayer() {
const playerRef = useRef(null);
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
- const isMobile = useIsMobile();
const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack);
diff --git a/components/auth/auth-gate.tsx b/components/auth/auth-gate.tsx
index 1fbbe5e0..2fd94606 100644
--- a/components/auth/auth-gate.tsx
+++ b/components/auth/auth-gate.tsx
@@ -72,6 +72,9 @@ export function AuthGateProvider({ children }: { children: React.ReactNode }) {
if (user && (open || resolversRef.current.length > 0)) {
const pending = resolversRef.current;
resolversRef.current = [];
+ // Intentional: react to the user becoming authenticated by closing the
+ // gate and resolving the queued promises.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setOpen(false);
pending.forEach((r) => r(true));
}
diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx
index 53ec6dd1..004a9425 100644
--- a/components/notifications/notifications-provider.tsx
+++ b/components/notifications/notifications-provider.tsx
@@ -27,6 +27,7 @@ import {
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { notifications } from "@/lib/social";
+import { sadhna } from "@/lib/sadhna";
type NotificationsContextValue = {
/** Live unread count for the header bell. */
@@ -86,13 +87,26 @@ export function NotificationsProvider({
// Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body).
useEffect(() => {
- if (!user) return;
+ if (!user?.id) return;
refreshUnread();
}, [user?.id, refreshUnread]);
- // Open the realtime connection while signed in.
+ // Report the browser's timezone (once per signed-in session) so sādhana
+ // reminders fire at the user's local clock time. Best-effort.
useEffect(() => {
- if (!user) return;
+ if (!user?.id) return;
+ try {
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ if (tz) void sadhna.setTimezone(tz).catch(() => {});
+ } catch {
+ /* Intl unavailable — leave the server default (UTC) */
+ }
+ }, [user?.id]);
+
+ // Open the realtime connection while signed in.
+ const userId = user?.id;
+ useEffect(() => {
+ if (!userId) return;
const key = process.env.NEXT_PUBLIC_SOKETI_KEY;
const host = process.env.NEXT_PUBLIC_SOKETI_HOST;
if (!key || !host) return; // realtime not configured
@@ -100,7 +114,7 @@ export function NotificationsProvider({
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
- const channelName = `notifications.${user.id}`;
+ const channelName = `notifications.${userId}`;
let cancelled = false;
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
@@ -124,6 +138,16 @@ export function NotificationsProvider({
setLastEventAt(Date.now());
toast(messageFor(data ?? {}), { duration: 4000 });
});
+ // Sādhana reminders are ephemeral nudges — toast only, no unread bump
+ // (they aren't persisted to the notification center).
+ channel.bind(
+ "reminder",
+ (data: { title?: string; body?: string }) => {
+ toast(data?.body || data?.title || "A gentle reminder", {
+ duration: 6000,
+ });
+ },
+ );
cleanup = () => {
channel.unbind_all();
pusher.unsubscribe(channelName);
@@ -135,7 +159,7 @@ export function NotificationsProvider({
cancelled = true;
cleanup?.();
};
- }, [user?.id]);
+ }, [userId]);
return (
{
+ if (loading || !userId) return;
+ let cancelled = false;
+
+ // Async eligibility check — never setState synchronously in the effect.
+ (async () => {
+ if (!pushSupported() || pushPermission() !== "default") return;
+ const dismissed = Number(window.localStorage.getItem(DISMISS_KEY) || 0);
+ if (dismissed && Date.now() - dismissed < COOLDOWN_MS) return;
+ if (!(await pushConfigured())) return;
+ if (await isPushEnabled()) return;
+ if (!cancelled) setShow(true);
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [userId, loading]);
+
+ function dismiss() {
+ window.localStorage.setItem(DISMISS_KEY, String(Date.now()));
+ setShow(false);
+ }
+
+ async function enable() {
+ setBusy(true);
+ try {
+ await enablePush();
+ toast.success("Notifications enabled 🙏");
+ setShow(false);
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "Couldn't enable notifications.");
+ // A denial / dismissal shouldn't keep re-prompting.
+ dismiss();
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ if (!show) return null;
+
+ return (
+
+
+
+
+
+
+
+ Stay with your sādhana
+
+
+ Turn on notifications for daily reminders, follow requests, and
+ more — even when the app is closed.
+
+
+
+ {busy ? "Enabling…" : "Enable"}
+
+
+ Not now
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/push/push-toggle.tsx b/components/push/push-toggle.tsx
new file mode 100644
index 00000000..85e069e4
--- /dev/null
+++ b/components/push/push-toggle.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+/**
+ * PushToggle — the settings control for browser push notifications. Reads the
+ * current state on mount (supported? permission? server-configured?
+ * subscribed?) and lets the user enable/disable. Renders as a settings row
+ * matching the rest of /settings.
+ */
+
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ disablePush,
+ enablePush,
+ isPushEnabled,
+ pushConfigured,
+ pushPermission,
+ pushSupported,
+} from "@/lib/push";
+
+type State =
+ | "loading"
+ | "unsupported"
+ | "unavailable" // server has no VAPID keys
+ | "denied" // browser permission blocked
+ | "on"
+ | "off";
+
+export function PushToggle() {
+ const [state, setState] = useState("loading");
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ if (!pushSupported()) return finish("unsupported");
+ if (pushPermission() === "denied") return finish("denied");
+ if (!(await pushConfigured())) return finish("unavailable");
+ finish((await isPushEnabled()) ? "on" : "off");
+ function finish(s: State) {
+ if (!cancelled) setState(s);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ async function toggle() {
+ setBusy(true);
+ try {
+ if (state === "on") {
+ await disablePush();
+ setState("off");
+ toast.success("Push notifications turned off");
+ } else {
+ await enablePush();
+ setState("on");
+ toast.success("Push notifications turned on");
+ }
+ } catch (err) {
+ const msg =
+ err instanceof Error ? err.message : "Something went wrong.";
+ // A fresh block surfaces as a permission error — reflect it.
+ if (pushPermission() === "denied") setState("denied");
+ toast.error(msg);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const description = describe(state);
+
+ return (
+
+ );
+}
+
+function describe(state: State): string {
+ switch (state) {
+ case "on":
+ return "On — you'll be notified about follow requests and more, even when the app is closed.";
+ case "off":
+ return "Get notified about follow requests and more, even when the app is closed.";
+ case "denied":
+ return "Blocked in your browser. Enable notifications for this site in your browser settings, then reload.";
+ case "unsupported":
+ return "This browser doesn't support push notifications.";
+ case "unavailable":
+ return "Push notifications aren't available right now.";
+ default:
+ return "Checking…";
+ }
+}
diff --git a/components/sadhna/confetti.tsx b/components/sadhna/confetti.tsx
new file mode 100644
index 00000000..0269a0e9
--- /dev/null
+++ b/components/sadhna/confetti.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+/**
+ * Confetti — a lightweight, dependency-free confetti shower for the reward
+ * celebration. Spawns a fixed set of colored pieces that fall with a little
+ * horizontal drift and spin, then fade. Pointer-events-none so it never
+ * blocks the dialog beneath it. Honors prefers-reduced-motion.
+ */
+
+import { useMemo } from "react";
+
+const COLORS = [
+ "#f59e0b",
+ "#fb7185",
+ "#34d399",
+ "#60a5fa",
+ "#fbbf24",
+ "#f472b6",
+ "#a78bfa",
+ "#facc15",
+];
+
+// Deterministic pseudo-random in [0,1) from a seed. Math.sin is pure, so this
+// keeps the confetti layout stable across renders (no Math.random impurity).
+function rand(seed: number): number {
+ const x = Math.sin(seed * 127.1) * 43758.5453;
+ return x - Math.floor(x);
+}
+
+export function Confetti({ pieces = 140 }: { pieces?: number }) {
+ // Computed once per mount; varying by index keeps it deterministic-ish
+ // without Date/Math.random concerns (this is a normal component).
+ const items = useMemo(
+ () =>
+ Array.from({ length: pieces }, (_, i) => {
+ const r = (k: number) => rand(i * 9.17 + k * 3.13);
+ return {
+ left: r(1) * 100,
+ delay: r(2) * 0.5,
+ dur: 2.4 + r(3) * 1.8,
+ rot: r(4) * 360,
+ drift: (r(5) * 2 - 1) * 70,
+ size: 6 + r(6) * 7,
+ color: COLORS[i % COLORS.length],
+ round: r(7) < 0.3,
+ };
+ }),
+ [pieces],
+ );
+
+ return (
+
+
+ {items.map((p, i) => (
+
+ ))}
+
+ );
+}
diff --git a/components/sadhna/reminder-toggle.tsx b/components/sadhna/reminder-toggle.tsx
new file mode 100644
index 00000000..2fb32cd5
--- /dev/null
+++ b/components/sadhna/reminder-toggle.tsx
@@ -0,0 +1,104 @@
+"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(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 (
+
+ );
+}
diff --git a/components/sadhna/reward-celebration.tsx b/components/sadhna/reward-celebration.tsx
new file mode 100644
index 00000000..747cae8b
--- /dev/null
+++ b/components/sadhna/reward-celebration.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+/**
+ * RewardCelebration — a full-screen confetti celebration shown when the
+ * devotee earns a streak gift. Centered card with the reward image, its
+ * cosmic name, and symbolic meaning. Dismissing it acknowledges the gift
+ * (via the caller) so it surfaces exactly once.
+ *
+ * Scrolls internally on small screens (max-h + overflow) and reads in both
+ * themes (card/foreground tokens). Esc or backdrop-click closes.
+ */
+
+import { useEffect } from "react";
+import { createPortal } from "react-dom";
+import Image from "next/image";
+import { motion } from "framer-motion";
+import { Confetti } from "./confetti";
+import type { SadhnaRewardGrant } from "@/lib/sadhna";
+import { useMounted } from "@/lib/use-mounted";
+
+export function RewardCelebration({
+ grant,
+ onClose,
+}: {
+ grant: SadhnaRewardGrant;
+ onClose: () => void;
+}) {
+ const mounted = useMounted();
+
+ // Esc to close + lock background scroll while open.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKey);
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ document.body.style.overflow = prev;
+ };
+ }, [onClose]);
+
+ if (!mounted) return null;
+ const r = grant.reward;
+
+ return createPortal(
+
+
+
+
+
+ A gift for your sādhana
+
+
+ You've earned a reward!
+
+
+ For {grant.milestone} days of unbroken sādhana 🙏
+
+
+
+
+
+ {r.name}
+
+ {r.meaning ? (
+
+ {r.meaning}
+
+ ) : null}
+
+
+ Hari bol! 🌸
+
+
+
,
+ document.body,
+ );
+}
diff --git a/components/sadhna/rewards-gallery.tsx b/components/sadhna/rewards-gallery.tsx
new file mode 100644
index 00000000..9ceb9a74
--- /dev/null
+++ b/components/sadhna/rewards-gallery.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+/**
+ * RewardsGallery — the collected-gifts section on /sadhna-streak. Shows each
+ * unique reward the devotee has earned as a tappable tile (image + short
+ * name); tapping opens a detail dialog with the full name + symbolic
+ * meaning + which streak day it was earned. Remaining catalog slots show as
+ * locked tiles to entice completion.
+ *
+ * Built to read in both themes (card/foreground tokens), keep names legible
+ * (line-clamped, never overflowing), and resize the large R2 images via
+ * next/image.
+ */
+
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import Image from "next/image";
+import { Gift } from "lucide-react";
+import type { SadhnaRewardGrant } from "@/lib/sadhna";
+import { useMounted } from "@/lib/use-mounted";
+
+export function RewardsGallery({
+ collected,
+ nextRewardDate,
+}: {
+ collected: SadhnaRewardGrant[];
+ nextRewardDate: string | null;
+}) {
+ const [detail, setDetail] = useState(null);
+
+ // De-dupe by reward (collected is newest-first, so the first wins).
+ const seen = new Set();
+ const unique: SadhnaRewardGrant[] = [];
+ for (const g of collected) {
+ if (!seen.has(g.reward.key)) {
+ seen.add(g.reward.key);
+ unique.push(g);
+ }
+ }
+
+ return (
+
+
+
+
+ Sacred Rewards
+
+
+
+ {unique.length === 0 ? (
+
+
+
+ No gifts yet. Keep your sādhana streak — a sacred reward arrives
+ every 7 days.
+
+
+ ) : (
+
+ {unique.map((g) => (
+
setDetail(g)}
+ className="group flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background p-2 text-center transition-colors hover:border-primary/40 hover:bg-muted/40"
+ >
+
+
+
+
+ {shortName(g.reward.name)}
+
+
+ ))}
+
+ )}
+
+ {nextRewardDate ? (
+
+ Next gift on{" "}
+
+ {fmtLong(nextRewardDate)}
+
+
+ ) : null}
+
+ {detail ? (
+ setDetail(null)} />
+ ) : null}
+
+ );
+}
+
+function RewardDetail({
+ grant,
+ onClose,
+}: {
+ grant: SadhnaRewardGrant;
+ onClose: () => void;
+}) {
+ const mounted = useMounted();
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
+ window.addEventListener("keydown", onKey);
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ window.removeEventListener("keydown", onKey);
+ document.body.style.overflow = prev;
+ };
+ }, [onClose]);
+ if (!mounted) return null;
+ const r = grant.reward;
+
+ return createPortal(
+
+
+
+
+
+ {r.name}
+
+ {r.meaning ? (
+
+ {r.meaning}
+
+ ) : null}
+
+ Earned on day {grant.milestone} of your streak
+
+
+ Close
+
+
+
,
+ document.body,
+ );
+}
+
+/** The evocative term before the em-dash, for compact tile labels. */
+function shortName(name: string): string {
+ const parts = name.split("—");
+ return (parts[0] ?? name).trim();
+}
+
+function fmtLong(date: string): string {
+ const d = new Date(`${date}T00:00:00Z`);
+ if (Number.isNaN(d.getTime())) return "";
+ return d.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+}
diff --git a/components/sadhna/streak-calendar.tsx b/components/sadhna/streak-calendar.tsx
new file mode 100644
index 00000000..7f27d75c
--- /dev/null
+++ b/components/sadhna/streak-calendar.tsx
@@ -0,0 +1,361 @@
+"use client";
+
+/**
+ * StreakCalendar — the sādhana streak calendar with week/month views.
+ *
+ * Shows the current/best streak and a grid of days; a lotus blooms beneath
+ * every day the sādhana was completed (both disciplines). The user can
+ * switch between a single-week view and a full-month grid, and page
+ * backward/forward through time. "Next" stops at the current period — there
+ * is nothing to show in the future.
+ *
+ * Streak counters come from the dashboard payload; the grid itself is fed by
+ * the /sadhna/history range endpoint, fetched per visible period and cached
+ * so paging back to a period you've seen is instant. Everything is bucketed
+ * by UTC calendar day to match the backend.
+ */
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Check, ChevronLeft, ChevronRight, Gift } from "lucide-react";
+import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna";
+import { StreakFlame } from "@/components/sadhna/streak-flame";
+import { cn } from "@/lib/utils";
+
+const DAY = 86400000;
+const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
+
+type View = "week" | "month";
+
+// ── UTC date helpers (operate on epoch-ms at UTC midnight) ──
+function ymd(ms: number): string {
+ return new Date(ms).toISOString().slice(0, 10);
+}
+function startOfDayUTC(d: Date): number {
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
+}
+function startOfWeek(ms: number): number {
+ return ms - new Date(ms).getUTCDay() * DAY;
+}
+
+export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) {
+ const todayMs = startOfDayUTC(new Date());
+ const todayStr = ymd(todayMs);
+
+ const [view, setView] = useState("week");
+ // A day inside the visible period. Starts on today.
+ const [anchorMs, setAnchorMs] = useState(todayMs);
+
+ // Visible grid range + metadata for the chosen view.
+ const grid = useMemo(() => {
+ if (view === "week") {
+ const start = startOfWeek(anchorMs);
+ return { start, weeks: 1, monthIndex: -1 };
+ }
+ const d = new Date(anchorMs);
+ const monthStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
+ const monthEnd = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0);
+ const start = startOfWeek(monthStart);
+ const end = startOfWeek(monthEnd) + 6 * DAY;
+ const weeks = Math.round((end - start) / (7 * DAY)) + 1;
+ return { start, weeks, monthIndex: d.getUTCMonth() };
+ }, [view, anchorMs]);
+
+ const fromStr = ymd(grid.start);
+ const toStr = ymd(grid.start + (grid.weeks * 7 - 1) * DAY);
+
+ // Fetch the visible range, cached by range key; race-safe.
+ const cacheRef = useRef>>(new Map());
+ const reqRef = useRef(0);
+ const [byDate, setByDate] = useState>(new Map());
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ const key = `${fromStr}_${toStr}`;
+ const cached = cacheRef.current.get(key);
+ if (cached) {
+ setByDate(cached);
+ return;
+ }
+ const token = ++reqRef.current;
+ setLoading(true);
+ sadhna
+ .history(fromStr, toStr)
+ .then((days) => {
+ const m = new Map((days ?? []).map((d) => [d.date, d] as const));
+ cacheRef.current.set(key, m);
+ if (reqRef.current === token) setByDate(m);
+ })
+ .catch(() => {
+ /* keep whatever's shown */
+ })
+ .finally(() => {
+ if (reqRef.current === token) setLoading(false);
+ });
+ }, [fromStr, toStr]);
+
+ // The dashboard's trailing-7-day `week` is the freshest authoritative
+ // source for recent days (it reflects a completion the moment dash
+ // reloads on mount/focus). Overlay it on top of the fetched history so the
+ // current week's cells — especially today — always agree with the streak
+ // counter, no matter how the history fetch raced.
+ const dashByDate = useMemo(
+ () => new Map((dash?.week ?? []).map((d) => [d.date, d] as const)),
+ [dash],
+ );
+ const dayFor = (date: string) => dashByDate.get(date) ?? byDate.get(date);
+
+ // Navigation bounds: never page past the current period.
+ const currentPeriodStart =
+ view === "week"
+ ? startOfWeek(todayMs)
+ : Date.UTC(new Date(todayMs).getUTCFullYear(), new Date(todayMs).getUTCMonth(), 1);
+ const visiblePeriodStart =
+ view === "week"
+ ? grid.start
+ : Date.UTC(new Date(anchorMs).getUTCFullYear(), new Date(anchorMs).getUTCMonth(), 1);
+ const atCurrent = visiblePeriodStart >= currentPeriodStart;
+
+ const shift = (dir: -1 | 1) => {
+ if (view === "week") {
+ setAnchorMs((ms) => ms + dir * 7 * DAY);
+ } else {
+ const d = new Date(anchorMs);
+ setAnchorMs(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + dir, 1));
+ }
+ };
+
+ const streak = dash?.current_streak ?? 0;
+ const longest = dash?.longest_streak ?? 0;
+ const giftDate = dash?.next_reward_date ?? null;
+ const cells = Array.from({ length: grid.weeks * 7 }, (_, i) => grid.start + i * DAY);
+
+ return (
+
+ {/* Streak + view toggle */}
+
+
+
0 ? "bg-orange-500/15" : "bg-muted",
+ )}
+ >
+
+
+
+
+ {streak} day{streak === 1 ? "" : "s"}
+
+
+ {streak > 0 ? "current streak" : "start your streak today"}
+ {longest > 0 ? ` · best ${longest}` : ""}
+
+
+
+
+
+ {(["week", "month"] as View[]).map((v) => (
+ setView(v)}
+ className={cn(
+ "rounded-md px-2.5 py-1 capitalize transition-colors",
+ view === v
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {v}
+
+ ))}
+
+
+
+ {/* Period nav */}
+
+
shift(-1)}>
+
+
+
+
+ {view === "week"
+ ? weekRangeLabel(grid.start)
+ : monthLabel(anchorMs)}
+
+ {!atCurrent ? (
+ setAnchorMs(todayMs)}
+ className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
+ >
+ Today
+
+ ) : null}
+
+
shift(1)} disabled={atCurrent}>
+
+
+
+
+ {/* Weekday header (month view) */}
+ {view === "month" ? (
+
+ {WEEKDAYS.map((w) => (
+
+ {w}
+
+ ))}
+
+ ) : null}
+
+
+ {cells.map((ms) => {
+ const date = ymd(ms);
+ return (
+ todayMs}
+ isGift={!!giftDate && date === giftDate}
+ inMonth={view === "week" || new Date(ms).getUTCMonth() === grid.monthIndex}
+ />
+ );
+ })}
+
+
+ );
+}
+
+function NavButton({
+ label,
+ onClick,
+ disabled,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ disabled?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function DayCell({
+ date,
+ day,
+ view,
+ isToday,
+ isFuture,
+ isGift,
+ inMonth,
+}: {
+ date: string;
+ day: SadhnaDay | undefined;
+ view: View;
+ isToday: boolean;
+ isFuture: boolean;
+ isGift: boolean;
+ inMonth: boolean;
+}) {
+ const completed = !!day?.completed;
+ const partial = !!day && !completed && (day.chant_done || day.quote_done);
+ const isWeek = view === "week";
+
+ return (
+
+ {isWeek ? (
+
+ {WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]}
+
+ ) : null}
+
+ {dayOfMonth(date)}
+
+ {/* Marker slot — fixed height keeps every column's baseline aligned. */}
+
+ {completed ? (
+
+ ) : isGift ? (
+
+ ) : null}
+
+
+ );
+}
+
+// ── labels ──
+function dayOfMonth(date: string): number | string {
+ const d = new Date(`${date}T00:00:00Z`);
+ return Number.isNaN(d.getTime()) ? "" : d.getUTCDate();
+}
+function monthLabel(ms: number): string {
+ return new Date(ms).toLocaleDateString(undefined, {
+ month: "long",
+ year: "numeric",
+ timeZone: "UTC",
+ });
+}
+function weekRangeLabel(startMs: number): string {
+ const f = new Date(startMs);
+ const l = new Date(startMs + 6 * DAY);
+ const fm = f.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" });
+ const lm = l.toLocaleDateString(undefined, { month: "short", timeZone: "UTC" });
+ return fm === lm
+ ? `${fm} ${f.getUTCDate()} – ${l.getUTCDate()}`
+ : `${fm} ${f.getUTCDate()} – ${lm} ${l.getUTCDate()}`;
+}
diff --git a/components/sadhna/streak-cta.tsx b/components/sadhna/streak-cta.tsx
new file mode 100644
index 00000000..edac1669
--- /dev/null
+++ b/components/sadhna/streak-cta.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+/**
+ * SadhnaStreakCTA — a compact streak card for the homepage. Tapping it opens
+ * /sadhna-streak. For a signed-in devotee it shows their live streak and
+ * today's status; for visitors it's a gentle invitation to begin. The feed
+ * will sit alongside this later.
+ */
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { ChevronRight } from "lucide-react";
+import { useAuth } from "@/components/auth/auth-provider";
+import { sadhna, type SadhnaDashboard } from "@/lib/sadhna";
+import { StreakFlame } from "@/components/sadhna/streak-flame";
+
+export function SadhnaStreakCTA() {
+ const { user, loading } = useAuth();
+ const authed = !loading && !!user;
+ const [dash, setDash] = useState(null);
+
+ useEffect(() => {
+ // Only fetch when signed in; never setState synchronously here. A stale
+ // dash from a previous session is ignored in render via `data` below.
+ if (!authed) return;
+ let cancelled = false;
+ sadhna
+ .dashboard()
+ .then((d) => {
+ if (!cancelled) setDash(d);
+ })
+ .catch(() => {
+ /* card stays in its invitation state */
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [authed]);
+
+ // Ignore any leftover dashboard when not authed (e.g. after sign-out).
+ const data = authed ? dash : null;
+ const streak = data?.current_streak ?? 0;
+
+ const title = !authed
+ ? "Daily Sādhana"
+ : streak > 0
+ ? `${streak} day${streak === 1 ? "" : "s"} sadhna streak`
+ : "Start your streak";
+
+ const subtitle = !authed
+ ? "Chant your rounds and share a verse each day"
+ : data?.today_completed
+ ? "Today's sādhana is complete 🌸"
+ : data?.chant_done || data?.quote_done
+ ? "One offering done — finish today"
+ : "Chant and post a quote to complete today";
+
+ return (
+
+ 0 ? "bg-orange-500/15" : "bg-muted")
+ }
+ >
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+ );
+}
diff --git a/components/sadhna/streak-flame.tsx b/components/sadhna/streak-flame.tsx
new file mode 100644
index 00000000..939fa47c
--- /dev/null
+++ b/components/sadhna/streak-flame.tsx
@@ -0,0 +1,41 @@
+/**
+ * StreakFlame — a vivid two-tone flame for the sādhana streak badge: a warm
+ * orange body with an inner gold flame. Pass `muted` for a zero / broken
+ * streak (greyed out). Size it with a height/size class.
+ */
+export function StreakFlame({
+ className,
+ muted = false,
+}: {
+ className?: string;
+ muted?: boolean;
+}) {
+ const outer = muted
+ ? ["#c2c6cd", "#9aa0a8", "#868c95"]
+ : ["#ff9d2e", "#ff5722", "#e23a0e"];
+ const inner = muted ? ["#dde0e4", "#bcc0c6"] : ["#ffe08a", "#ffb01f"];
+ // One flame renders at a time, so fixed gradient ids are fine.
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/social/collection-grid-card.tsx b/components/social/collection-grid-card.tsx
index f480f469..01898c08 100644
--- a/components/social/collection-grid-card.tsx
+++ b/components/social/collection-grid-card.tsx
@@ -16,7 +16,7 @@
import Image from "next/image";
import Link from "next/link";
-import { Globe, Lock } from "lucide-react";
+import { Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Collection } from "@/lib/social";
import { quoteCardBgStyle } from "./post-card-shared";
diff --git a/components/social/page-bookmarks.tsx b/components/social/page-bookmarks.tsx
index a1f5b56e..5e8f3915 100644
--- a/components/social/page-bookmarks.tsx
+++ b/components/social/page-bookmarks.tsx
@@ -155,9 +155,10 @@ export function PageBookmarks() {
// Belt-and-braces: when this component unmounts (route change), tear
// down every mark so the next page starts clean.
useEffect(() => {
+ const appliedIds = appliedIdsRef.current;
return () => {
removeBookmarkMarks();
- appliedIdsRef.current.clear();
+ appliedIds.clear();
};
}, []);
diff --git a/components/social/update-background-modal.tsx b/components/social/update-background-modal.tsx
index 7aa213e9..38b43b1b 100644
--- a/components/social/update-background-modal.tsx
+++ b/components/social/update-background-modal.tsx
@@ -218,8 +218,8 @@ export function UpdateBackgroundModal({
Update Background
- Pick an image to set as the card's background, or remove the
- current one.
+ Pick an image to set as the card's background, or remove
+ the current one.