feat: add webpush and sadhna tracker

This commit is contained in:
gosangam
2026-06-08 15:48:12 +05:30
parent ecb32d2e85
commit 0334e03c3c
52 changed files with 2118 additions and 545 deletions
-3
View File
@@ -6,7 +6,6 @@ import { notFound } from "next/navigation";
import {
getVaContent,
getVaSidebarItems,
type VaChapter,
type VaSidebarItem,
} from "@/lib/va";
import { getProseChapter } from "@/lib/api";
@@ -61,8 +60,6 @@ export default async function VaChapterPage({
const next =
currentIdx < sidebarItems.length - 1 ? sidebarItems[currentIdx + 1] : null;
const chapterNum = (content as VaChapter).chapter;
return (
<div className="min-h-screen bg-background">
<PageHeader sticky>
-2
View File
@@ -7,7 +7,6 @@
*/
import { use, useEffect, useState } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Globe, Lock, FolderPlus } from "lucide-react";
import {
@@ -29,7 +28,6 @@ export default function PublicCollectionPage(props: {
useEffect(() => {
let cancelled = false;
setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
+1 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Search, X, Menu, ChevronLeft, ChevronRight } from "lucide-react";
import { Input } from "@/components/ui/input";
@@ -289,7 +289,6 @@ export function GlossaryClient({
letters,
letterCounts,
}: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState("");
+2
View File
@@ -7,6 +7,7 @@ import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
import { TextSelectionToolbar } from "@/components/text-selection-toolbar";
import { TextHighlighter } from "@/components/text-highlighter";
import { PageBookmarks } from "@/components/social/page-bookmarks";
import { PushPrompt } from "@/components/push/push-prompt";
/**
* Default app layout — the reading-app chrome that wraps the home page, all
@@ -37,6 +38,7 @@ export default function AppLayout({
<TextHighlighter />
</Suspense>
<PageBookmarks />
<PushPrompt />
</>
);
}
-1
View File
@@ -3,7 +3,6 @@
import { useState, useMemo, useRef, useEffect } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
+5
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Compass, LogIn, Sparkles } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
/**
* / — the Home (Feed) gate.
@@ -79,6 +80,10 @@ export default function HomePage() {
</p>
) : null}
{/* Daily sādhana streak — a simple entry point until the feed lands. */}
<div className="mt-6 w-full max-w-md">
<SadhnaStreakCTA />
</div>
</div>
</main>
);
+246 -69
View File
@@ -3,34 +3,98 @@
/**
* /sadhna-streak/chant — the japa counter.
*
* Shows the Hare Kṛṣṇa mahā-mantra with a big tap-to-count round counter
* running toward the devotee's daily target (their profile's
* daily_chanting_rounds, falling back to 16). Save persists the count;
* reaching the target marks today's chanting discipline complete.
* Shows the full opening sequence — Śrīla Prabhupāda Praṇāma (×1),
* Pañca-tattva Praṇāma (×1), then the Hare Kṛṣṇa mahā-mantra (×108) — with a
* big tap-to-count round counter running toward the devotee's daily target
* (profile's daily_chanting_rounds, falling back to 16). Progress saves
* automatically (debounced); reaching the target marks today's chanting
* complete. The mantra script toggles between English (IAST) and Sanskrit
* (Devanāgarī), and Śrīla Prabhupāda's recorded japa can be played alongside.
*
* Auth-gated like the rest of /sadhna-streak. It seeds the counter from
* today's already-saved progress so a devotee can chant in sittings.
* Auth-gated like the rest of /sadhna-streak; seeds from today's saved
* progress so a devotee can chant in sittings.
*/
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Check, Minus, RotateCcw } from "lucide-react";
import {
ArrowLeft,
Check,
Loader2,
Minus,
Pause,
Play,
RotateCcw,
} from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useAudioPlayer, type AudioTrack } from "@/components/audio-player";
import { sadhna } from "@/lib/sadhna";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const MANTRA = [
"Hare Kṛṣṇa Hare Kṛṣṇa",
"Kṛṣṇa Kṛṣṇa Hare Hare",
"Hare Rāma Hare Rāma",
"Rāma Rāma Hare Hare",
];
type Lang = "en" | "sa";
const FALLBACK_TARGET = 16;
const LANG_KEY = "sadhna.mantra.lang";
// Śrīla Prabhupāda's recorded japa — played through the global player so it
// survives navigation and pauses any other audio.
const JAPA_TRACK: AudioTrack = {
url: "https://s3.prabhupadasays.com/sp-audio/Japa/Srila%20Prabhupada%20-%20Chanting%20Japa.mp3",
title: "Śrīla Prabhupāda — Japa",
subtitle: "Chanting the Hare Kṛṣṇa mahā-mantra",
type: "kirtan",
};
// The opening praṇāma mantras, recited once before the rounds of japa.
const PRELUDE: { title: string; times: string; en: string[]; sa: string[] }[] =
[
{
title: "Śrīla Prabhupāda Praṇāma",
times: "×1",
en: [
"nama oṁ viṣṇu-pādāya kṛṣṇa-preṣṭhāya bhū-tale",
"śrīmate bhaktivedānta-svāmin iti nāmine",
"namas te sārasvate deve gaura-vāṇī-pracāriṇe",
"nirviśeṣa-śūnyavādi-pāścātya-deśa-tāriṇe",
],
sa: [
"नम ॐ विष्णु-पादाय कृष्ण-प्रेष्ठाय भूतले",
"श्रीमते भक्तिवेदांत-स्वामिन् इति नामिने ।",
"नमस्ते सारस्वते देवे गौर-वाणी-प्रचारिणे",
"निर्विशेष-शून्यवादि-पाश्चात्य-देश-तारिणे ॥",
],
},
{
title: "Pañca-tattva Praṇāma",
times: "×1",
en: [
"(jaya) śrī-kṛṣṇa-caitanya prabhu-nityānanda",
"śrī-advaita gadādhara śrīvāsādi-gaura-bhakta-vṛnda",
],
sa: [
"जय श्री कृष्ण-चैतन्य प्रभु-नित्यानंद",
"श्री-अद्वैत गदाधर श्रीवास आदि गौर-भक्त-वृंद ।",
],
},
];
const MAHAMANTRA: Record<Lang, string[]> = {
en: [
"Hare Kṛṣṇa Hare Kṛṣṇa",
"Kṛṣṇa Kṛṣṇa Hare Hare",
"Hare Rāma Hare Rāma",
"Rāma Rāma Hare Hare",
],
sa: [
"हरे कृष्ण हरे कृष्ण",
"कृष्ण कृष्ण हरे हरे ।",
"हरे राम हरे राम",
"राम राम हरे हरे ॥",
],
};
export default function ChantPage() {
const { user, loading } = useAuth();
@@ -40,8 +104,24 @@ export default function ChantPage() {
const [target, setTarget] = useState(FALLBACK_TARGET);
const [count, setCount] = useState(0);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState(0); // count value at last save
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
// Lazy initializer reads the saved script preference. Guarded for SSR; the
// mantra UI only renders client-side (after auth resolves), so this can't
// cause a hydration mismatch.
const [lang, setLang] = useState<Lang>(() => {
if (typeof window === "undefined") return "en";
const stored = window.localStorage.getItem(LANG_KEY);
return stored === "sa" || stored === "en" ? stored : "en";
});
const { currentTrack, isPlaying, play, togglePlayPause } = useAudioPlayer();
const japaActive = currentTrack?.url === JAPA_TRACK.url;
const japaPlaying = japaActive && isPlaying;
const savedRef = useRef(0); // last count persisted to the server
const completedRef = useRef(false); // so we toast completion only once
// Auth gate.
useEffect(() => {
@@ -66,7 +146,8 @@ export default function ChantPage() {
if (cancelled) return;
setTarget(d.target_rounds || FALLBACK_TARGET);
setCount(d.rounds_completed || 0);
setSavedAt(d.rounds_completed || 0);
savedRef.current = d.rounds_completed || 0;
completedRef.current = !!d.chant_done;
setState("ready");
})
.catch(() => !cancelled && setState("error"));
@@ -75,29 +156,46 @@ export default function ChantPage() {
};
}, [user]);
const inc = useCallback(() => {
setCount((c) => Math.min(target, c + 1));
}, [target]);
const persist = useCallback(async (value: number) => {
setSaveStatus("saving");
try {
const d = await sadhna.saveChant(value);
savedRef.current = value;
setTarget(d.target_rounds || FALLBACK_TARGET);
setSaveStatus("saved");
if (d.chant_done && !completedRef.current) {
completedRef.current = true;
toast.success("Today's chanting is complete 🙏");
}
if (!d.chant_done) completedRef.current = false;
} catch {
setSaveStatus("error");
}
}, []);
// Auto-save: debounce a save whenever the count drifts from what's stored.
useEffect(() => {
if (state !== "ready" || count === savedRef.current) return;
const t = window.setTimeout(() => void persist(count), 700);
return () => window.clearTimeout(t);
}, [count, state, persist]);
const inc = useCallback(
() => setCount((c) => Math.min(target, c + 1)),
[target],
);
const dec = useCallback(() => setCount((c) => Math.max(0, c - 1)), []);
const reset = useCallback(() => setCount(0), []);
const save = useCallback(async () => {
setSaving(true);
try {
const d = await sadhna.saveChant(count);
setSavedAt(count);
setTarget(d.target_rounds || FALLBACK_TARGET);
toast.success(
d.chant_done
? "Today's chanting complete 🙏"
: `Saved — ${count} of ${d.target_rounds} rounds`,
);
} catch {
toast.error("Could not save. Please try again.");
} finally {
setSaving(false);
}
}, [count]);
const chooseLang = useCallback((l: Lang) => {
setLang(l);
window.localStorage.setItem(LANG_KEY, l);
}, []);
const toggleAudio = useCallback(() => {
if (japaActive) togglePlayPause();
else play(JAPA_TRACK);
}, [japaActive, togglePlayPause, play]);
if (loading || !user) {
return (
@@ -110,11 +208,10 @@ export default function ChantPage() {
}
const complete = count >= target;
const dirty = count !== savedAt;
const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0;
return (
<main className="flex-1 w-full max-w-md mx-auto px-4 sm:px-6 py-5 flex flex-col">
<main className="flex-1 w-full max-w-lg mx-auto px-4 sm:px-6 py-5 flex flex-col">
<div className="flex items-center justify-between">
<Link
href="/sadhna-streak"
@@ -133,20 +230,87 @@ export default function ChantPage() {
</button>
</div>
{/* Mantra */}
<div className="mt-6 text-center">
<p className="text-xs uppercase tracking-widest text-muted-foreground">
The Mahā-mantra
{/* Script toggle + japa audio */}
<div className="mt-4 flex items-center justify-between gap-3">
<div className="inline-flex rounded-lg border border-border p-0.5 text-xs">
{(["en", "sa"] as Lang[]).map((l) => (
<button
key={l}
type="button"
onClick={() => chooseLang(l)}
className={cn(
"rounded-md px-3 py-1 transition-colors",
lang === l
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{l === "en" ? "English" : "Sanskrit"}
</button>
))}
</div>
<button
type="button"
onClick={toggleAudio}
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
aria-pressed={japaPlaying}
>
{japaPlaying ? (
<Pause className="size-3.5 text-primary" />
) : (
<Play className="size-3.5 text-primary" />
)}
{japaPlaying ? "Pause japa" : "Play japa"}
</button>
</div>
{/* Opening praṇāma mantras */}
<section className="mt-5 space-y-3">
<p className="text-center text-[11px] uppercase tracking-widest text-muted-foreground">
Before you begin
</p>
<div className="mt-3 space-y-1 grid grid-cols-1 md:grid-cols-2 font-serif text-lg sm:text-xl text-foreground leading-relaxed">
{MANTRA.map((line) => (
<p key={line}>{line}</p>
{PRELUDE.map((m) => (
<div
key={m.title}
className="rounded-2xl border border-border bg-card/60 p-4 text-center"
>
<div className="flex items-center justify-center gap-2">
<h3 className="text-sm font-semibold text-foreground">
{m.title}
</h3>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{m.times}
</span>
</div>
<div className="mt-2 space-y-0.5 font-serif text-[15px] sm:text-base leading-relaxed text-foreground/90">
{m[lang].map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
))}
</section>
{/* Mahā-mantra */}
<div className="mt-6 text-center">
<div className="flex items-center justify-center gap-2">
<p className="text-[11px] uppercase tracking-widest text-muted-foreground">
The Mahā-mantra
</p>
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
×108
</span>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-1 font-serif text-lg sm:text-xl leading-relaxed text-foreground md:grid-cols-2">
{MAHAMANTRA[lang].map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
</div>
{/* Big tap-to-count dial */}
<div className="mt-8 flex flex-col items-center">
<div className="mt-7 flex flex-col items-center">
<button
onClick={inc}
disabled={state !== "ready" || complete}
@@ -160,9 +324,19 @@ export default function ChantPage() {
)}
aria-label="Count one round"
>
{/* progress ring */}
<svg className="absolute inset-0 size-full -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="46" fill="none" stroke="currentColor" strokeOpacity="0.08" strokeWidth="4" />
<svg
className="absolute inset-0 size-full -rotate-90"
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="currentColor"
strokeOpacity="0.08"
strokeWidth="4"
/>
<circle
cx="50"
cy="50"
@@ -205,25 +379,28 @@ export default function ChantPage() {
</button>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground">
One round is 108 names on the mālā.
</p>
{/* Save */}
<div className="mt-auto pt-8">
<Button
onClick={save}
disabled={saving || !dirty || state !== "ready"}
size="lg"
className="w-full"
>
{saving ? "Saving…" : dirty ? "Save progress" : "Saved"}
</Button>
{/* Auto-save status */}
<div className="mt-6 mb-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
{state === "error" ? (
<p className="mt-2 text-center text-xs text-destructive">
Could not load today`&apos;s progress.
</p>
) : null}
<span className="text-destructive">
Could not load today&apos;s progress.
</span>
) : saveStatus === "saving" ? (
<>
<Loader2 className="size-3.5 animate-spin" /> Saving…
</>
) : saveStatus === "error" ? (
<span className="text-destructive">
Couldn&apos;t save — keep chanting, we&apos;ll retry.
</span>
) : saveStatus === "saved" ? (
<>
<Check className="size-3.5 text-primary" strokeWidth={3} /> Progress
saved
</>
) : (
<span>One round is 108 names on the mālā</span>
)}
</div>
</main>
);
+100 -180
View File
@@ -3,12 +3,13 @@
/**
* /sadhna-streak — the devotee's daily sādhana home.
*
* • a thin 7-day streak strip across the top
* • a streak calendar with week/month views and back/forward paging; each
* day shows its state, today highlighted, and a lotus blooms beneath
* every day the sādhana was completed
* • the current/best streak counter
* • two daily disciplines as action cards: finish today's chanting, and
* post at least one quote from the Vāṇī — each flips to a "done" state
* once complete
* • a plant that grows from a seedling toward a tree as the streak holds,
* and withers back through its stages (eventually dying) when it breaks
*
* Auth-gated like /notifications and /u/me. Re-fetches on focus so a quote
* posted elsewhere (posting is selection-driven while reading) reflects
@@ -18,11 +19,14 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { BookOpenText, Check, Flame, BadgePlus } from "lucide-react";
import Image from "next/image";
import { BookOpenText, Check } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna";
import { SadhnaVine } from "@/components/sadhna/sadhna-vine";
import { sadhna, type SadhnaDashboard, type SadhnaRewardGrant } from "@/lib/sadhna";
import { StreakCalendar } from "@/components/sadhna/streak-calendar";
import { RewardsGallery } from "@/components/sadhna/rewards-gallery";
import { RewardCelebration } from "@/components/sadhna/reward-celebration";
import { cn } from "@/lib/utils";
export default function SadhnaStreakPage() {
@@ -32,6 +36,13 @@ export default function SadhnaStreakPage() {
const [dash, setDash] = useState<SadhnaDashboard | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [rewards, setRewards] = useState<{
collected: SadhnaRewardGrant[];
total: number;
} | null>(null);
// Guards the celebration from re-opening between dismiss and the dashboard
// refetch (and lets a *new* milestone's gift still celebrate).
const [ackedMilestone, setAckedMilestone] = useState<number | null>(null);
// Auth gate — same pattern as /notifications.
useEffect(() => {
@@ -56,14 +67,41 @@ export default function SadhnaStreakPage() {
.catch(() => setState("error"));
}, []);
const loadRewards = useCallback(() => {
sadhna
.rewards()
.then(setRewards)
.catch(() => {
/* gallery is non-critical */
});
}, []);
// Load once we have a user, and again whenever the tab regains focus.
useEffect(() => {
if (!user) return;
load();
const onFocus = () => load();
loadRewards();
const onFocus = () => {
load();
loadRewards();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [user, load]);
}, [user, load, loadRewards]);
// A freshly earned, not-yet-celebrated gift to show confetti for.
const pending = dash?.pending_reward ?? null;
const showCelebration = !!pending && pending.milestone !== ackedMilestone;
const closeCelebration = useCallback(async () => {
if (pending) setAckedMilestone(pending.milestone);
try {
await sadhna.ackRewards();
} catch {
/* best-effort */
}
load(); // clears pending_reward
loadRewards(); // the new gift now appears in the gallery
}, [pending, load, loadRewards]);
if (loading || !user) {
return (
@@ -76,91 +114,42 @@ export default function SadhnaStreakPage() {
}
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4">
{/* Streak strip */}
<StreakStrip dash={dash} loading={state === "loading"} />
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-5">
<header className="text-center space-y-0.5">
<h1 className="font-[family-name:var(--font-title)] text-2xl sm:text-3xl font-semibold text-foreground">
<h1 className="font-(family-name:--font-title) text-2xl sm:text-3xl font-semibold text-foreground">
Daily Sādhana
</h1>
<p className="text-sm text-muted-foreground">{vineCaption(dash)}</p>
<p className="text-sm text-muted-foreground">{caption(dash)}</p>
</header>
{state === "error" ? (
<p className="rounded-2xl border border-border bg-card p-4 text-center text-sm text-destructive">
Could not load your sādhana. Please try again.
</p>
) : (
<StreakCalendar dash={dash} />
)}
{/* Action items */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ChantAction dash={dash} />
<QuoteAction dash={dash} />
</div>
{/* The creeper — climbs the pole, one fold per day of the streak */}
<VinePanel dash={dash} error={state === "error"} />
{/* Collected gifts */}
<RewardsGallery
collected={rewards?.collected ?? []}
nextRewardDate={dash?.next_reward_date ?? null}
/>
{/* Celebration when a gift is freshly earned */}
{showCelebration && pending ? (
<RewardCelebration grant={pending} onClose={closeCelebration} />
) : null}
</main>
);
}
// ─── Streak strip ────────────────────────────────────────────────────────
function StreakStrip({
dash,
loading,
}: {
dash: SadhnaDashboard | null;
loading: boolean;
}) {
const week = dash?.week ?? placeholderWeek();
const streak = dash?.current_streak ?? 0;
return (
<div className="flex items-center justify-between gap-3 rounded-full border border-border bg-card/60 px-4 py-2">
<div className="flex items-center gap-1.5 shrink-0">
<Flame
className={cn(
"size-4",
streak > 0 ? "text-orange-500" : "text-muted-foreground",
)}
/>
<span className="text-sm font-semibold text-foreground tabular-nums">
{streak}
</span>
<span className="text-xs text-muted-foreground hidden sm:inline">
day{streak === 1 ? "" : "s"}
</span>
</div>
<div className={cn("flex items-center gap-2", loading && "opacity-40")}>
{week.map((day, i) => (
<DayDot key={day.date} day={day} isToday={i === week.length - 1} />
))}
</div>
</div>
);
}
function DayDot({ day, isToday }: { day: SadhnaDay; isToday: boolean }) {
const partial = !day.completed && (day.chant_done || day.quote_done);
return (
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"size-5 rounded-full flex items-center justify-center transition-colors",
day.completed
? "bg-primary text-primary-foreground"
: partial
? "bg-primary/20 ring-1 ring-primary/50"
: "bg-muted",
isToday && !day.completed && "ring-2 ring-primary/60",
)}
>
{day.completed ? <Check className="size-3" strokeWidth={3} /> : null}
</div>
<span className="text-[10px] leading-none text-muted-foreground">
{weekdayLetter(day.date)}
</span>
</div>
);
}
// ─── Action cards ──────────────────────────────────────────────────────
function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
@@ -170,15 +159,21 @@ function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
return (
<ActionCard
href="/sadhna-streak/chant"
icon={<BadgePlus className="size-5" />}
icon={
<Image
src="/icons/chanting-beads.svg"
alt=""
width={48}
height={48}
className="size-12"
unoptimized
/>
}
title="Finish today's chanting"
subtitle={
done
? `${rounds} rounds chanted`
: `${rounds} / ${target} rounds`
done ? `${rounds} rounds chanted` : `${rounds} / ${target} rounds`
}
done={done}
cta={rounds > 0 ? "Continue" : "Begin"}
/>
);
}
@@ -187,16 +182,15 @@ function QuoteAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.quote_done ?? false;
return (
<ActionCard
href="/books"
icon={<BookOpenText className="size-5" />}
title="Post a quote from the Vāṇī"
href="/search"
icon={<BookOpenText className="size-8" />}
title="Post a quote"
subtitle={
done
? "Shared today — thank you"
: "Select a passage while reading, then Share to Feed"
}
done={done}
cta="Find a verse"
/>
);
}
@@ -207,124 +201,50 @@ function ActionCard({
title,
subtitle,
done,
cta,
}: {
href: string;
icon: React.ReactNode;
title: string;
subtitle: string;
done: boolean;
cta: string;
}) {
return (
<Link
href={href}
className={cn(
"group relative flex flex-col gap-2 rounded-2xl border p-4 transition-colors",
"group relative flex items-center gap-3.5 rounded-2xl border p-4 transition-colors",
done
? "border-primary/40 bg-primary/5"
: "border-border bg-card hover:border-primary/40 hover:bg-muted/40",
)}
>
<div className="flex items-center justify-between">
<span
className={cn(
"inline-flex size-9 items-center justify-center rounded-xl",
done
? "bg-primary text-primary-foreground"
: "bg-primary/10 text-primary",
)}
>
{done ? <Check className="size-5" strokeWidth={3} /> : icon}
</span>
{done ? (
<span className="text-xs font-medium text-primary">Done</span>
) : (
<span className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
{cta} →
</span>
<span
className={cn(
"inline-flex size-14 shrink-0 items-center justify-center rounded-xl",
done
? "bg-primary text-primary-foreground"
: "bg-primary/10 text-primary",
)}
</div>
<div className="space-y-0.5">
<p className="text-sm font-semibold text-foreground leading-snug">
>
{done ? <Check className="size-8" strokeWidth={3} /> : icon}
</span>
<div className="min-w-0 space-y-0.5">
<p className="text-base font-semibold text-foreground leading-snug">
{title}
</p>
<p className="text-xs text-muted-foreground leading-snug">{subtitle}</p>
<p className="text-sm text-muted-foreground leading-snug">{subtitle}</p>
</div>
</Link>
);
}
// ─── Vine panel ───────────────────────────────────────────────────────
// ─── Copy ──────────────────────────────────────────────────────────────
function VinePanel({
dash,
error,
}: {
dash: SadhnaDashboard | null;
error: boolean;
}) {
return (
<section className="relative h-[68svh] min-h-[420px] rounded-2xl border border-border bg-gradient-to-b from-sky-50/50 to-emerald-50/30 dark:from-sky-950/20 dark:to-emerald-950/10 overflow-hidden">
{error ? (
<div className="absolute inset-0 flex items-center justify-center px-4">
<p className="text-sm text-destructive text-center">
Could not load your sādhana. Please try again.
</p>
</div>
) : dash ? (
<SadhnaVine
days={dash.vine_days}
targetRounds={dash.target_rounds}
growth={dash.growth}
alive={dash.alive}
totalStreak={dash.current_streak}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm text-muted-foreground">Loading your vine…</p>
</div>
)}
</section>
);
}
// ─── 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.";
}
-207
View File
@@ -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 (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">Not available.</p>
</main>
);
}
return <Preview />;
}
/** 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 (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-4">
<div className="flex items-center gap-2">
<span className="rounded bg-amber-500/15 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-400">
Dev
</span>
<h1 className="font-[family-name:var(--font-title)] text-xl font-semibold text-foreground">
Vine preview
</h1>
</div>
{/* Controls */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 rounded-2xl border border-border bg-card p-4">
<Slider
label="Streak (finished folds)"
value={completed}
min={0}
max={30}
onChange={setCompleted}
/>
<Slider
label="Health (growth)"
value={growth}
min={0}
max={100}
onChange={setGrowth}
/>
<Slider
label="Today's rounds"
value={todayRounds}
min={0}
max={target}
disabled={todayComplete}
onChange={setTodayRounds}
/>
<Slider
label="Today's posts"
value={todayPosts}
min={0}
max={3}
disabled={todayComplete}
onChange={setTodayPosts}
/>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={todayComplete}
onChange={(e) => setTodayComplete(e.target.checked)}
className="size-4 accent-primary"
/>
Today already complete
</label>
<div className="flex flex-wrap gap-2">
<Chip onClick={() => setGrowth(Math.min(100, completed * 4))}>
sync health → streak
</Chip>
<Chip onClick={() => setGrowth(0)}>kill</Chip>
</div>
</div>
{/* Live vine — same panel chrome as the real page */}
<section className="relative h-[68svh] min-h-[420px] rounded-2xl border border-border bg-gradient-to-b from-sky-50/50 to-emerald-50/30 dark:from-sky-950/20 dark:to-emerald-950/10 overflow-hidden">
<SadhnaVine
days={days}
targetRounds={target}
growth={growth}
alive={alive}
totalStreak={streak}
/>
</section>
</main>
);
}
function Slider({
label,
value,
min,
max,
onChange,
disabled,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
disabled?: boolean;
}) {
return (
<div className={cn("space-y-1", disabled && "opacity-40")}>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums text-foreground">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-primary"
/>
</div>
);
}
function Chip({
children,
onClick,
}: {
children: React.ReactNode;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
{children}
</button>
);
}
+3 -5
View File
@@ -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;
+7 -5
View File
@@ -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" });
+10
View File
@@ -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() {
<ReadingSettings variant="bare" />
</Section>
<Section
title="Notifications"
description="Receive alerts on this device — even when the app is closed."
>
<PushToggle />
<ReminderToggle />
</Section>
<Section title="Authentication">
{/* Suspense boundary required because LinkResult reads
useSearchParams — Next.js bails out of SSR otherwise. */}
@@ -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;
@@ -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({
@@ -422,7 +422,6 @@ function MonthSection({
export function TranscriptionTypeBrowser({
transcriptions,
type,
typeLabel,
totalCount,
}: {
@@ -42,7 +42,6 @@ const MONTH_NAMES = [
];
function MonthSection({
month,
label,
items,
defaultOpen,
-1
View File
@@ -29,7 +29,6 @@ export function PostsGrid({
useEffect(() => {
let cancelled = false;
setLoading(true);
profile
.feed(handle, 30)
.then((rows) => {
@@ -32,7 +32,6 @@ export function ScrollFeed({
useEffect(() => {
let cancelled = false;
setLoading(true);
profile
.feed(handle, 60)
.then((rows) => {
-1
View File
@@ -18,7 +18,6 @@ export default function MyBookmarksPage() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
bookmarks
.list()
.then((r) => setList(r ?? []))
-1
View File
@@ -35,7 +35,6 @@ export default function CollectionDetailPage(props: {
useEffect(() => {
let cancelled = false;
setLoading(true);
collections
.get(id)
.then(({ collection, posts }) => {
-1
View File
@@ -51,7 +51,6 @@ function MyProfilePageInner() {
useEffect(() => {
if (!user) return;
setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
-1
View File
@@ -26,7 +26,6 @@ export default function MyPostsFeedPage() {
useEffect(() => {
if (!user) return;
setLoading(true);
posts
.mine()
.then((rows) => setFeed(rows ?? []))
+9 -7
View File
@@ -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<string>("");
// 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<string>(
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);
+4
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
/**
* OG card for a single post.
*
@@ -107,6 +109,7 @@ export async function GET(
{hasImage ? (
<>
<img
alt=""
src={bgImageUrl}
width={1200}
height={630}
@@ -191,6 +194,7 @@ export async function GET(
>
{post.author_avatar_url ? (
<img
alt=""
src={post.author_avatar_url}
width={64}
height={64}
+3
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; next/image doesn't work inside OG image generation. */
/**
* OG card for a user profile (`/u/<handle>`).
*
@@ -109,6 +111,7 @@ export async function GET(
>
{profile.avatar_url ? (
<img
alt=""
src={profile.avatar_url}
width={220}
height={220}
+3
View File
@@ -1,3 +1,5 @@
/* eslint-disable @next/next/no-img-element -- Satori (ImageResponse) only
renders raw <img>; 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 */}
<img
alt=""
src={imgSrc}
width={420}
height={590}