12 Commits
Author SHA1 Message Date
gosangam 647a803b96 feat: add post like 2026-06-09 16:34:10 +05:30
gosangam e36afdd1a5 feat: add followers/following list 2026-06-09 13:20:44 +05:30
gosangam ef6e280524 fix: sync sadhna calendar with local timezone 2026-06-09 00:39:12 +05:30
gosangam cf733062f8 feat: copy caption in post actions menu 2026-06-08 23:45:01 +05:30
gosangam 421b41cade fix: push notification 2026-06-08 17:52:27 +05:30
gosangam deeda40c9d fix: push notification 2026-06-08 17:17:50 +05:30
gosangam 90df5bc784 fix: webpush for iOS 2026-06-08 16:09:09 +05:30
gosangam 0334e03c3c feat: add webpush and sadhna tracker 2026-06-08 15:48:12 +05:30
gosangam ecb32d2e85 wip: sadhna streak 2026-06-07 22:32:34 +05:30
gosangam d5fb350652 fix: rybbit blocking using next rewrite 2026-06-07 00:52:49 +05:30
gosangam bcffe9684f feat: add websocket and analytics 2026-06-07 00:23:54 +05:30
gosangam 0db339254d fix: text selection 2026-06-05 14:13:13 +05:30
76 changed files with 4236 additions and 193 deletions
+18
View File
@@ -0,0 +1,18 @@
# BFF API Server (server-side fetches)
BFF_URL=http://localhost:8080
# BFF API Server (browser-side fetches; must be publicly reachable in prod)
NEXT_PUBLIC_BFF_URL=http://localhost:8080
# RTF Viewer (development only)
ALLOW_RTF=true
NEXT_PUBLIC_OAUTH_ENABLED=true
# SOKETI
NEXT_PUBLIC_SOKETI_KEY=spsays
NEXT_PUBLIC_SOKETI_HOST=ws.prabhupadasays.org
NEXT_PUBLIC_SOKETI_PORT=443
NEXT_PUBLIC_SOKETI_FORCE_TLS=true
# Analytics
NEXT_PUBLIC_RYBBIT_HOST=https://analytics.krispire.cloud
+1 -1
View File
@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
.env
# vercel
.vercel
-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 {
+39 -13
View File
@@ -19,11 +19,13 @@ import Link from "next/link";
import { Bell } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { notifications, type AppNotification } from "@/lib/social";
export default function NotificationsPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const { lastEventAt, clearUnread } = useNotifications();
const router = useRouter();
const [items, setItems] = useState<AppNotification[]>([]);
@@ -42,7 +44,9 @@ export default function NotificationsPage() {
};
}, [loading, user, requireAuth, router]);
// Load the feed + mark everything read once we have a user.
// Load the feed + mark everything read once we have a user, and again
// whenever a realtime notification arrives while the page is open
// (lastEventAt bumps). Marking read here also clears the header badge.
useEffect(() => {
if (!user) return;
let cancelled = false;
@@ -57,13 +61,16 @@ export default function NotificationsPage() {
.catch(() => {
if (!cancelled) setState("error");
});
notifications.markAllRead().catch(() => {
/* non-critical */
});
notifications
.markAllRead()
.then(() => clearUnread())
.catch(() => {
/* non-critical */
});
return () => {
cancelled = true;
};
}, [user]);
}, [user, lastEventAt, clearUnread]);
if (loading || !user) {
return (
@@ -92,7 +99,7 @@ export default function NotificationsPage() {
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((n) => (
<NotificationRow key={n.id} n={n} />
<NotificationRow key={n.id} n={n} selfHandle={user.handle} />
))}
</ul>
)}
@@ -100,9 +107,15 @@ export default function NotificationsPage() {
);
}
function NotificationRow({ n }: { n: AppNotification }) {
function NotificationRow({
n,
selfHandle,
}: {
n: AppNotification;
selfHandle: string;
}) {
const display = n.actor_name || (n.actor_handle ? `@${n.actor_handle}` : "Someone");
const message = messageFor(n.kind);
const message = messageFor(n);
const unread = !n.read_at;
const body = (
@@ -124,10 +137,18 @@ function NotificationRow({ n }: { n: AppNotification }) {
</div>
);
// Link to the actor's profile when we know their handle.
return n.actor_handle ? (
// A like links to your own post; everything else links to the actor's
// profile (when we know their handle).
const href =
n.kind === "post_like" && n.post_id
? `/u/${selfHandle}/posts#post-${n.post_id}`
: n.actor_handle
? `/u/${n.actor_handle}`
: null;
return href ? (
<li>
<Link href={`/u/${n.actor_handle}`} className="block">
<Link href={href} className="block">
{body}
</Link>
</li>
@@ -145,12 +166,17 @@ function cnRow(unread: boolean) {
/** Human-readable tail for each notification kind. Add cases as new
* notification kinds are introduced on the backend. */
function messageFor(kind: string): string {
switch (kind) {
function messageFor(n: AppNotification): string {
switch (n.kind) {
case "follow_request":
return "requested to follow you.";
case "follow_accepted":
return "accepted your follow request.";
case "post_like": {
const others = (n.actor_count ?? 1) - 1;
if (others <= 0) return "liked your post.";
return `and ${others} ${others === 1 ? "other" : "others"} liked your post.`;
}
default:
return "sent you a notification.";
}
+6 -1
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.
@@ -25,7 +26,7 @@ export default function HomePage() {
const authed = !loading && !!user;
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24">
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24 select-none">
<div className="flex flex-col items-center text-center gap-5">
<span
aria-hidden
@@ -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>
);
+407
View File
@@ -0,0 +1,407 @@
"use client";
/**
* /sadhna-streak/chant — the japa counter.
*
* 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; seeds from today's saved
* progress so a devotee can chant in sittings.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
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 { cn } from "@/lib/utils";
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();
const { requireAuth } = useAuthGate();
const router = useRouter();
const [target, setTarget] = useState(FALLBACK_TARGET);
const [count, setCount] = useState(0);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
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(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
// Seed from today's saved progress.
useEffect(() => {
if (!user) return;
let cancelled = false;
sadhna
.dashboard()
.then((d) => {
if (cancelled) return;
setTarget(d.target_rounds || FALLBACK_TARGET);
setCount(d.rounds_completed || 0);
savedRef.current = d.rounds_completed || 0;
completedRef.current = !!d.chant_done;
setState("ready");
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [user]);
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 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 (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">
{loading ? "Loading…" : "Sign in to chant."}
</p>
</main>
);
}
const complete = count >= target;
const pct = target > 0 ? Math.min(100, (count / target) * 100) : 0;
return (
<main className="flex-1 w-full max-w-lg mx-auto px-4 sm:px-6 py-5 flex flex-col select-none">
<div className="flex items-center justify-between">
<Link
href="/sadhna-streak"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
Sādhana
</Link>
<button
onClick={reset}
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Reset counter"
>
<RotateCcw className="size-3.5" />
Reset
</button>
</div>
{/* 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>
{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-7 flex flex-col items-center">
<button
onClick={inc}
disabled={state !== "ready" || complete}
className={cn(
"relative grid place-items-center size-56 rounded-full select-none transition-transform active:scale-[0.97]",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
complete
? "bg-primary/10"
: "bg-card hover:bg-muted/50 cursor-pointer",
"border border-border",
)}
aria-label="Count one round"
>
<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"
r="46"
fill="none"
className="text-primary transition-[stroke-dashoffset] duration-500 ease-out"
stroke="currentColor"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={2 * Math.PI * 46}
strokeDashoffset={(1 - pct / 100) * 2 * Math.PI * 46}
/>
</svg>
<div className="text-center">
<div className="text-6xl font-semibold tabular-nums text-foreground leading-none">
{count}
</div>
<div className="mt-1 text-sm text-muted-foreground">
of {target} rounds
</div>
{complete ? (
<div className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary">
<Check className="size-3.5" strokeWidth={3} /> Complete
</div>
) : (
<div className="mt-2 text-[11px] text-muted-foreground">
tap for each round
</div>
)}
</div>
</button>
<button
onClick={dec}
disabled={count === 0}
className="mt-4 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors disabled:opacity-40"
aria-label="Remove one round"
>
<Minus className="size-4" /> One round
</button>
</div>
{/* Auto-save status */}
<div className="mt-6 mb-2 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
{state === "error" ? (
<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>
);
}
+250
View File
@@ -0,0 +1,250 @@
"use client";
/**
* /sadhna-streak — the devotee's daily sādhana home.
*
* • 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
*
* Auth-gated like /notifications and /u/me. Re-fetches on focus so a quote
* posted elsewhere (posting is selection-driven while reading) reflects
* here when the devotee returns.
*/
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
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 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() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const router = useRouter();
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(() => {
if (loading || user) return;
let cancelled = false;
(async () => {
const ok = await requireAuth();
if (!ok && !cancelled) router.replace("/");
})();
return () => {
cancelled = true;
};
}, [loading, user, requireAuth, router]);
const load = useCallback(() => {
sadhna
.dashboard()
.then((d) => {
setDash(d);
setState("ready");
})
.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();
loadRewards();
const onFocus = () => {
load();
loadRewards();
};
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [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 (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">
{loading ? "Loading…" : "Sign in to track your sādhana."}
</p>
</main>
);
}
return (
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-5 select-none">
<header className="text-center space-y-0.5">
<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">{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>
{/* 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>
);
}
// ─── Action cards ──────────────────────────────────────────────────────
function ChantAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.chant_done ?? false;
const target = dash?.target_rounds ?? 16;
const rounds = dash?.rounds_completed ?? 0;
return (
<ActionCard
href="/sadhna-streak/chant"
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={done}
/>
);
}
function QuoteAction({ dash }: { dash: SadhnaDashboard | null }) {
const done = dash?.quote_done ?? false;
return (
<ActionCard
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}
/>
);
}
function ActionCard({
href,
icon,
title,
subtitle,
done,
}: {
href: string;
icon: React.ReactNode;
title: string;
subtitle: string;
done: boolean;
}) {
return (
<Link
href={href}
className={cn(
"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",
)}
>
<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",
)}
>
{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-sm text-muted-foreground leading-snug">{subtitle}</p>
</div>
</Link>
);
}
// ─── Copy ──────────────────────────────────────────────────────────────
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.";
}
return "Chant your rounds and post a quote to complete today.";
}
+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}
+38 -12
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
import { ogDefault } from "@/lib/og";
import { ThemeProvider } from "@/components/theme-provider";
@@ -6,6 +7,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
import { AuthProvider } from "@/components/auth/auth-provider";
import { AuthGateProvider } from "@/components/auth/auth-gate";
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
import { SharePostProvider } from "@/components/social/share-post-provider";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
@@ -44,6 +46,20 @@ export const metadata: Metadata = {
title: "Prabhupāda Says",
description:
"Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
// Makes the site a standalone Home Screen app on iOS — the prerequisite
// for Web Push on iPhone/iPad (iOS 16.4+). Emits
// <meta name="apple-mobile-web-app-capable" content="yes"> etc.
appleWebApp: {
capable: true,
title: "Prabhupāda Says",
statusBarStyle: "default",
},
// Home Screen / tab icons. The apple-touch-icon is what iOS uses for the
// installed PWA (the prerequisite for Web Push on iPhone/iPad).
icons: {
icon: "/icon-192.png",
apple: "/apple-icon.png",
},
...ogDefault(),
};
@@ -73,22 +89,32 @@ export default function RootLayout({
disableTransitionOnChange
>
<AuthProvider>
<AuthGateProvider>
<AudioPlayerProvider>
<TooltipProvider>
<SharePostProvider>
{children}
{/* Toaster and FloatingPlayer are global — toasts and
<NotificationsProvider>
<AuthGateProvider>
<AudioPlayerProvider>
<TooltipProvider>
<SharePostProvider>
{children}
{/* Toaster and FloatingPlayer are global — toasts and
currently-playing audio survive navigation between
route groups. */}
<FloatingPlayer />
<Toaster />
</SharePostProvider>
</TooltipProvider>
</AudioPlayerProvider>
</AuthGateProvider>
<FloatingPlayer />
<Toaster />
</SharePostProvider>
</TooltipProvider>
</AudioPlayerProvider>
</AuthGateProvider>
</NotificationsProvider>
</AuthProvider>
</ThemeProvider>
{/* Rybbit analytics — loads after hydration (afterInteractive is
next/script's defer-equivalent). Site-wide via the root layout,
loaded once across navigations. */}
<Script
src="/api/script.js"
data-site-id="1"
strategy="afterInteractive"
/>
</body>
</html>
);
+30
View File
@@ -0,0 +1,30 @@
import type { MetadataRoute } from "next";
/**
* Web App Manifest — makes the site installable as a standalone PWA. This is
* what lets iOS (16.4+) deliver Web Push: notifications only work once the
* site is added to the Home Screen and launched standalone. Next serves this
* at /manifest.webmanifest and links it automatically.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Prabhupāda Says",
short_name: "Prabhupāda",
description:
"Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
start_url: "/",
display: "standalone",
background_color: "#fdf7ee",
theme_color: "#fdf7ee",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}
@@ -6,7 +6,6 @@ import {
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from "react";
import { useAudioPlayer } from "./audio-context";
import { AudioWaveAnimation } from "./audio-wave";
@@ -35,41 +34,6 @@ import {
const MINIMIZE_DELAY = 8000;
/**
* `useSyncExternalStore` is the React-recommended way to expose values
* from external systems (window, media-queries, etc.) without a
* mirror-into-state effect. The previous useEffect-+-setState version
* tripped React 19's "setState synchronously inside an effect" warning
* — this variant has no effect at all.
*
* On the server we default to false (desktop). The player only mounts
* when a track is playing, which is always after hydration, so the
* initial-paint value never matters for SSR markup.
*/
const MOBILE_QUERY = "(max-width: 767px)";
function subscribeMobile(callback: () => 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<HTMLDivElement>(null);
const [selectionToolbarVisible, setSelectionToolbarVisible] = useState(false);
const isMobile = useIsMobile();
const { minimized, setMinimized, resetTimer } =
useAutoMinimize(!!currentTrack);
+3
View File
@@ -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));
}
+7 -21
View File
@@ -2,33 +2,19 @@
/**
* NotificationBell — header affordance for signed-in users. Links to the
* /notifications page and shows a dot when there are unread items. The
* unread count is fetched once on mount (good enough until websocket/push
* delivery lands); visiting the page marks everything read.
* /notifications page and shows a dot when there are unread items.
*
* The unread count is read from NotificationsProvider, which seeds it from
* the REST endpoint and keeps it live over the Soketi WebSocket. Visiting
* the page marks everything read and clears the badge.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { Bell } from "lucide-react";
import { notifications } from "@/lib/social";
import { useNotifications } from "@/components/notifications/notifications-provider";
export function NotificationBell() {
const [unread, setUnread] = useState(0);
useEffect(() => {
let cancelled = false;
notifications
.unreadCount()
.then((r) => {
if (!cancelled) setUnread(r?.count ?? 0);
})
.catch(() => {
/* badge is non-critical — ignore */
});
return () => {
cancelled = true;
};
}, []);
const { unread } = useNotifications();
return (
<Link
@@ -0,0 +1,183 @@
"use client";
/**
* NotificationsProvider — holds the live unread-notification count and a
* WebSocket connection to our Soketi (Pusher-compatible) server so the
* bell badge + a toast update in realtime.
*
* Transport: subscribes to the PUBLIC channel `notifications.{userId}` and
* binds the `notification` event the BFF triggers on each follow-request /
* follow-accept. The event is a lightweight nudge — we bump the unread
* count locally and toast — while the authoritative list/count still come
* from the REST endpoints. (Public channel for now; once Soketi App auth
* is configured, switch to a `private-` channel + a /broadcasting/auth
* endpoint — only the channel name and a Pusher `authEndpoint` change.)
*
* Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent
* realtime silently disables and the bell falls back to its fetched count.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
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. */
unread: number;
/** Bumps on each realtime event; pages can depend on it to refetch. */
lastEventAt: number;
/** Re-fetch the authoritative unread count from the BFF. */
refreshUnread: () => void;
/** Zero the badge locally (after the page marks everything read). */
clearUnread: () => void;
};
const NotificationsContext = createContext<NotificationsContextValue | null>(
null,
);
type IncomingEvent = {
kind?: string;
actor_name?: string;
actor_handle?: string;
actor_avatar_url?: string | null;
};
function messageFor(e: IncomingEvent): string {
const who =
e.actor_name || (e.actor_handle ? `@${e.actor_handle}` : "Someone");
switch (e.kind) {
case "follow_request":
return `${who} requested to follow you`;
case "follow_accepted":
return `${who} accepted your follow request`;
case "post_like":
return `${who} liked your post`;
default:
return "You have a new notification";
}
}
export function NotificationsProvider({
children,
}: {
children: React.ReactNode;
}) {
const { user } = useAuth();
const [unread, setUnread] = useState(0);
const [lastEventAt, setLastEventAt] = useState(0);
const refreshUnread = useCallback(() => {
notifications
.unreadCount()
.then((r) => setUnread(r?.count ?? 0))
.catch(() => {
/* badge is non-critical */
});
}, []);
const clearUnread = useCallback(() => setUnread(0), []);
// Seed the count whenever the signed-in user changes (async — no
// synchronous setState in the effect body).
useEffect(() => {
if (!user?.id) return;
refreshUnread();
}, [user?.id, refreshUnread]);
// 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?.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
const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443");
const forceTLS =
(process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false";
const channelName = `notifications.${userId}`;
let cancelled = false;
// Dynamic import keeps pusher-js out of SSR (it touches window) and the
// initial bundle.
let cleanup: (() => void) | undefined;
void import("pusher-js").then(({ default: Pusher }) => {
if (cancelled) return;
const pusher = new Pusher(key, {
wsHost: host,
wsPort: port,
wssPort: port,
forceTLS,
enabledTransports: ["ws", "wss"],
disableStats: true,
// Ignored when wsHost is set, but required by the type.
cluster: "",
});
const channel = pusher.subscribe(channelName);
channel.bind("notification", (data: IncomingEvent) => {
setUnread((u) => u + 1);
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);
pusher.disconnect();
};
});
return () => {
cancelled = true;
cleanup?.();
};
}, [userId]);
return (
<NotificationsContext.Provider
value={{ unread, lastEventAt, refreshUnread, clearUnread }}
>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications(): NotificationsContextValue {
const ctx = useContext(NotificationsContext);
if (!ctx) {
throw new Error(
"useNotifications must be used inside <NotificationsProvider>",
);
}
return ctx;
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/**
* FollowListDrawer — the followers / following list for a profile. A thin
* wrapper over the generic virtualized UserListDrawer, wired to the
* relationships followers/following endpoints.
*/
import { relationships } from "@/lib/social";
import { UserListDrawer } from "@/components/social/user-list-drawer";
export type FollowMode = "followers" | "following";
export function FollowListDrawer({
open,
mode,
handle,
onClose,
}: {
open: boolean;
mode: FollowMode;
handle: string;
onClose: () => void;
}) {
return (
<UserListDrawer
open={open}
onClose={onClose}
title={mode === "followers" ? "Followers" : "Following"}
depsKey={`${mode}:${handle}`}
fetchPage={(params) =>
mode === "followers"
? relationships.followers(handle, params)
: relationships.following(handle, params)
}
/>
);
}
+61 -10
View File
@@ -34,6 +34,10 @@ import { Settings, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { RelationshipResult, RelationshipState } from "@/lib/social";
import { ProfileActions } from "@/components/profile/profile-actions";
import {
FollowListDrawer,
type FollowMode,
} from "@/components/profile/follow-list-drawer";
export function ProfileHeader({
avatarURL,
@@ -83,7 +87,12 @@ export function ProfileHeader({
// without a refetch. Re-seeds per profile via the call-site key.
const [followers, setFollowers] = useState(followerCount ?? 0);
const [following, setFollowing] = useState(followingCount ?? 0);
const [rel, setRel] = useState<RelationshipState | null>(relationship ?? null);
const [rel, setRel] = useState<RelationshipState | null>(
relationship ?? null,
);
// Which follow list (if any) is open in the drawer.
const [followList, setFollowList] = useState<FollowMode | null>(null);
const handleResult = (r: RelationshipResult) => {
setRel(r.relationship);
@@ -103,7 +112,7 @@ export function ProfileHeader({
/>
<div className="space-y-2">
<div className="space-y-1">
<h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight">
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
{name}
</h1>
<p className="text-sm text-muted-foreground">@{handle}</p>
@@ -116,8 +125,16 @@ export function ProfileHeader({
) : null}
{showCounts ? (
<>
<Stat n={followers} label="followers" />
<Stat n={following} label="following" />
<Stat
n={followers}
label="followers"
onClick={() => setFollowList("followers")}
/>
<Stat
n={following}
label="following"
onClick={() => setFollowList("following")}
/>
</>
) : null}
</div>
@@ -164,17 +181,51 @@ export function ProfileHeader({
onClose={() => setZoomOpen(false)}
/>
) : null}
{showCounts ? (
<FollowListDrawer
open={followList !== null}
mode={followList ?? "followers"}
handle={handle}
onClose={() => setFollowList(null)}
/>
) : null}
</>
);
}
/** One inline profile stat — bold count over a muted label. */
function Stat({ n, label }: { n: number; label: string }) {
return (
<div className="flex flex-col items-center leading-tight">
<span className="font-semibold text-lg text-foreground tabular-nums">{n}</span>
/** One inline profile stat — bold count over a muted label. Renders as a
* button (opening the follow list) when `onClick` is provided, else a plain
* div (e.g. the posts count). */
function Stat({
n,
label,
onClick,
}: {
n: number;
label: string;
onClick?: () => void;
}) {
const inner = (
<>
<span className="font-semibold text-lg text-foreground tabular-nums">
{n}
</span>
<span className="text-sm text-muted-foreground">{label}</span>
</div>
</>
);
if (!onClick) {
return (
<div className="flex flex-col items-center leading-tight">{inner}</div>
);
}
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-center leading-tight rounded-md px-2 -mx-1 py-0.5 transition-colors hover:bg-muted/60 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{inner}
</button>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
/**
* PushPrompt — a gentle on-load invitation to enable browser notifications,
* shown to signed-in users who haven't decided yet.
*
* Browsers require a user gesture to request notification permission, so this
* is a *soft* ask: the real `Notification.requestPermission()` only fires
* when the user taps "Enable". We show it when push is supported, the server
* has push configured, permission is still "default", the user isn't already
* subscribed, and they haven't recently dismissed it (7-day cooldown).
*/
import { useEffect, useState } from "react";
import { Bell, X } from "lucide-react";
import { toast } from "sonner";
import { useAuth } from "@/components/auth/auth-provider";
import {
enablePush,
isPushEnabled,
pushConfigured,
pushPermission,
pushSupported,
} from "@/lib/push";
const DISMISS_KEY = "push.prompt.dismissedAt";
const COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
export function PushPrompt() {
const { user, loading } = useAuth();
const [show, setShow] = useState(false);
const [busy, setBusy] = useState(false);
const userId = user?.id;
useEffect(() => {
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 (
<div className="fixed inset-x-0 bottom-20 z-40 flex justify-center px-4 md:bottom-4">
<div className="flex w-full max-w-md items-start gap-3 rounded-2xl border border-border bg-card p-4 shadow-lg">
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<Bell className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
Stay with your sādhana
</p>
<p className="mt-0.5 text-sm text-muted-foreground leading-snug">
Turn on notifications for daily reminders, follow requests, and
more — even when the app is closed.
</p>
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={enable}
disabled={busy}
className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-60"
>
{busy ? "Enabling…" : "Enable"}
</button>
<button
type="button"
onClick={dismiss}
className="inline-flex h-9 items-center justify-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Not now
</button>
</div>
</div>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss"
className="-mr-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="size-4" />
</button>
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
"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,
needsIOSInstall,
pushConfigured,
pushPermission,
pushSupported,
} from "@/lib/push";
type State =
| "loading"
| "unsupported"
| "ios-install" // iOS: must add to Home Screen first
| "unavailable" // server has no VAPID keys
| "denied" // browser permission blocked
| "on"
| "off";
export function PushToggle() {
const [state, setState] = useState<State>("loading");
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
if (!pushSupported()) {
return finish(needsIOSInstall() ? "ios-install" : "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 (
<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
</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>
);
}
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 "ios-install":
return "On iPhone & iPad, notifications work only from the Home Screen app. Tap the Share icon in Safari, choose “Add to Home Screen”, then open Prabhupāda Says from the new icon and turn this on.";
case "unsupported":
return "This browser doesn't support push notifications.";
case "unavailable":
return "Push notifications aren't available right now.";
default:
return "Checking…";
}
}
+85
View File
@@ -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 (
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
aria-hidden
>
<style>{`
@keyframes sadhna-confetti {
0% { transform: translate(0,-10%) rotateZ(0deg); opacity: 1; }
85% { opacity: 1; }
100% { transform: translate(var(--drift), 115vh) rotateZ(720deg); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sadhna-confetti-piece { display: none; }
}
`}</style>
{items.map((p, i) => (
<span
key={i}
className="sadhna-confetti-piece absolute top-0 block"
style={{
left: `${p.left}%`,
width: p.size,
height: p.size * (p.round ? 1 : 0.5),
background: p.color,
borderRadius: p.round ? "9999px" : "1px",
transform: `rotateZ(${p.rot}deg)`,
// CSS var consumed by the keyframe's final transform.
["--drift" as string]: `${p.drift}px`,
animation: `sadhna-confetti ${p.dur}s cubic-bezier(0.2,0.6,0.4,1) ${p.delay}s forwards`,
}}
/>
))}
</div>
);
}
+104
View File
@@ -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<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>
);
}
+102
View File
@@ -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(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<Confetti />
<motion.div
initial={{ scale: 0.85, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 20 }}
className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl select-none"
>
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
A gift for your sādhana
</p>
<h2 className="mt-1 font-(family-name:--font-title) text-2xl font-semibold text-foreground">
You&apos;ve earned a reward!
</h2>
<p className="mt-1 text-sm text-muted-foreground">
For {grant.milestone} days of unbroken sādhana 🙏
</p>
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-primary/15 blur-2xl" />
<Image
src={r.image_url}
alt={r.name}
width={256}
height={256}
sizes="256px"
className="relative size-full object-contain drop-shadow-xl"
/>
</div>
<h3 className="font-(family-name:--font-title) text-xl font-semibold text-foreground">
{r.name}
</h3>
{r.meaning ? (
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{r.meaning}
</p>
) : null}
<button
type="button"
onClick={onClose}
className="mt-6 inline-flex w-full items-center justify-center rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Hari bol! 🌸
</button>
</motion.div>
</div>,
document.body,
);
}
+177
View File
@@ -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<SadhnaRewardGrant | null>(null);
// De-dupe by reward (collected is newest-first, so the first wins).
const seen = new Set<string>();
const unique: SadhnaRewardGrant[] = [];
for (const g of collected) {
if (!seen.has(g.reward.key)) {
seen.add(g.reward.key);
unique.push(g);
}
}
return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-3 flex items-center gap-2">
<Gift className="size-5 text-primary" />
<h2 className="text-base font-semibold text-foreground">
Sacred Rewards
</h2>
</div>
{unique.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
<Gift className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
No gifts yet. Keep your sādhana streak — a sacred reward arrives
every 7 days.
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-2.5 sm:grid-cols-4">
{unique.map((g) => (
<button
key={g.reward.key}
type="button"
onClick={() => 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"
>
<div className="relative aspect-square w-full">
<Image
src={g.reward.image_url}
alt={g.reward.name}
fill
sizes="(max-width: 640px) 30vw, 120px"
className="object-contain p-1"
/>
</div>
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
{shortName(g.reward.name)}
</span>
</button>
))}
</div>
)}
{nextRewardDate ? (
<p className="mt-3.5 text-center text-xs text-muted-foreground">
Next gift on{" "}
<span className="font-medium text-foreground">
{fmtLong(nextRewardDate)}
</span>
</p>
) : null}
{detail ? (
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
) : null}
</section>
);
}
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(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl select-none">
<div className="relative mx-auto mb-4 flex size-40 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-primary/10 blur-2xl" />
<Image
src={r.image_url}
alt={r.name}
width={224}
height={224}
sizes="224px"
className="relative size-full object-contain drop-shadow-lg"
/>
</div>
<h3 className="font-(family-name:--font-title) text-xl font-semibold text-foreground">
{r.name}
</h3>
{r.meaning ? (
<p className="mx-auto mt-2 max-w-sm text-[15px] leading-relaxed text-muted-foreground">
{r.meaning}
</p>
) : null}
<p className="mt-4 text-xs text-muted-foreground">
Earned on day {grant.milestone} of your streak
</p>
<button
type="button"
onClick={onClose}
className="mt-5 inline-flex w-full items-center justify-center rounded-xl border border-border px-4 py-2.5 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
>
Close
</button>
</div>
</div>,
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",
});
}
+682
View File
@@ -0,0 +1,682 @@
"use client";
/**
* SadhnaVine — the sādhana creeper.
*
* A realistic crawler vine twines up a pole, gaining one full fold (a 360°
* wrap) for every day of the current streak — oldest at the base, climbing
* toward the top. An unfinished today is the soft growing tip above the last
* finished fold, tipped with a tendril and reaching a little higher as the
* day's two disciplines are met. Each finished fold carries a chip showing
* how many rounds were chanted and how many quotes were posted that day.
*
* The pole stands its full height from the start — the practice is to climb
* it. When the vine fills the pole a saffron flag crowns it. Lose the streak
* and the living vine you built is gone: you begin the climb again, and a
* faltering practice yellows, then browns, the foliage (the `growth` health
* score drives the palette).
*
* Rendering: the stem is a tapered, tube-shaded ribbon following a helix; its
* far half is drawn behind the pole and near half in front, so it wraps the
* pole convincingly. Heart-shaped leaves (veined, gradient-shaded, on
* petioles) and coiled tendrils dress the stem. Geometry is in real pixels,
* sized to the container via a ResizeObserver, so the day chips (HTML) line
* up with the SVG. A gentle CSS breeze sways leaves, tendrils and the tip;
* the stem stays put so the chips track it.
*
* The drawing math mirrors the standalone SVG study iterated with the
* render-svg tool, kept parametric so it scales with the container and the
* streak length.
*/
import { useEffect, useRef, useState } from "react";
import { CircleDot, MessageSquareQuote, Sparkles } from "lucide-react";
import type { VineDay } from "@/lib/sadhna";
import { cn } from "@/lib/utils";
const TAU = Math.PI * 2;
type Palette = {
stemFrontTop: string;
stemFrontMid: string;
stemFrontBot: string;
stemBack: string;
sheen: string;
leafFront: [string, string, string];
leafBack: [string, string, string];
vein: string;
tendril: string;
droop: number; // extra rotation toward the ground when wilting
dim: boolean;
};
function palette(growth: number, alive: boolean): Palette {
if (!alive || growth <= 0) {
return {
stemFrontTop: "#a98f63",
stemFrontMid: "#8c7149",
stemFrontBot: "#6f5836",
stemBack: "#5f4a2e",
sheen: "#cdb085",
leafFront: ["#bfa46f", "#9c8451", "#6f5a35"],
leafBack: ["#9c8451", "#7d683f", "#54462a"],
vein: "#5f4a2e",
tendril: "#8c7149",
droop: 22,
dim: true,
};
}
if (growth < 30) {
return {
stemFrontTop: "#9fb04a",
stemFrontMid: "#84983a",
stemFrontBot: "#5f7029",
stemBack: "#566b27",
sheen: "#cfe07f",
leafFront: ["#c2d85e", "#9bbf45", "#6f8a30"],
leafBack: ["#9bbf45", "#7ea136", "#5b7327"],
vein: "#566b27",
tendril: "#84983a",
droop: 8,
dim: false,
};
}
if (growth < 60) {
return {
stemFrontTop: "#57a046",
stemFrontMid: "#3f8a38",
stemFrontBot: "#2a6a2b",
stemBack: "#26602a",
sheen: "#7bbf5e",
leafFront: ["#74c044", "#45a23b", "#27702c"],
leafBack: ["#5aa844", "#3a8a34", "#1f5e26"],
vein: "#235f27",
tendril: "#4e9a40",
droop: 0,
dim: false,
};
}
return {
stemFrontTop: "#57a046",
stemFrontMid: "#3f8a38",
stemFrontBot: "#2a6a2b",
stemBack: "#235a27",
sheen: "#8fce6a",
leafFront: ["#7cc848", "#46a23c", "#27732f"],
leafBack: ["#5aa844", "#3a8a34", "#1f5e26"],
vein: "#1f5a23",
tendril: "#4e9a40",
droop: 0,
dim: false,
};
}
// Heart-shaped crawler leaf outline, tip up at -L, petiole at origin.
function leafOutline(L: number): string {
const w = L * 0.66;
return `M 0 ${-L * 0.2}
C ${w * 0.16} ${-L * 0.1} ${w * 0.36} 0 ${w * 0.52} ${-L * 0.02}
C ${w * 0.8} ${-L * 0.05} ${w} ${-L * 0.22} ${w} ${-L * 0.41}
C ${w} ${-L * 0.61} ${w * 0.64} ${-L * 0.81} ${w * 0.3} ${-L * 0.92}
C ${w * 0.18} ${-L * 0.96} ${w * 0.08} ${-L * 0.98} 0 ${-L}
C ${-w * 0.08} ${-L * 0.98} ${-w * 0.18} ${-L * 0.96} ${-w * 0.3} ${-L * 0.92}
C ${-w * 0.64} ${-L * 0.81} ${-w} ${-L * 0.61} ${-w} ${-L * 0.41}
C ${-w} ${-L * 0.22} ${-w * 0.8} ${-L * 0.05} ${-w * 0.52} ${-L * 0.02}
C ${-w * 0.36} 0 ${-w * 0.16} ${-L * 0.1} 0 ${-L * 0.2} Z`;
}
function leafVeins(L: number): string {
const w = L * 0.66;
const lat = (yf: number, xf: number, s: number) =>
`M0 ${-L * yf} Q ${s * w * xf * 0.55} ${-L * (yf + 0.1)} ${s * w * xf} ${-L * (yf + 0.2)}`;
return [
`M0 ${-L * 0.2} L0 ${-L * 0.95}`,
lat(0.3, 0.74, 1),
lat(0.3, 0.74, -1),
lat(0.48, 0.6, 1),
lat(0.48, 0.6, -1),
lat(0.64, 0.44, 1),
lat(0.64, 0.44, -1),
lat(0.78, 0.28, 1),
lat(0.78, 0.28, -1),
].join(" ");
}
export function SadhnaVine({
days,
targetRounds,
growth,
alive,
totalStreak,
className,
}: {
days: VineDay[];
targetRounds: number;
growth: number;
alive: boolean;
totalStreak: number;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const cr = entries[0].contentRect;
setSize({ w: cr.width, h: cr.height });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const W = size.w;
const H = size.h;
const ready = W > 40 && H > 140;
const pal = palette(growth, alive);
// ── Geometry ──
const centerX = W / 2;
const baseY = H - 34;
const topPad = 24;
const availH = Math.max(120, baseY - topPad);
const amp = Math.min(46, Math.max(22, W * 0.17));
const pitch = Math.min(86, Math.max(54, availH / 12));
const poleW = Math.min(14, Math.max(9, amp * 0.3));
const wBase = Math.min(15, Math.max(8, amp * 0.3));
const wTip = 3;
const maxWraps = Math.max(1, Math.floor(availH / pitch));
// Growing tip — today, still unfinished — extends with the day's progress.
const tip =
days.length > 0 && days[days.length - 1].in_progress
? days[days.length - 1]
: null;
const chantPortion =
targetRounds > 0
? Math.min(1, (tip?.rounds ?? 0) / targetRounds)
: (tip?.rounds ?? 0) > 0
? 1
: 0;
const quotePortion = (tip?.posts ?? 0) > 0 ? 1 : 0;
const partialFrac = tip ? 0.18 + 0.78 * ((chantPortion + quotePortion) / 2) : 0;
// Trim oldest folds beyond the pole's capacity.
const completedAll = days.filter((d) => d.completed).length;
const unitsNeeded = completedAll + (tip ? 1 : 0);
const hidden = Math.max(0, unitsNeeded - maxWraps);
const render = hidden > 0 ? days.slice(hidden) : days;
const renderCompleted = render.filter((d) => d.completed).length;
const renderTip =
render.length > 0 && render[render.length - 1].in_progress
? render[render.length - 1]
: null;
const topPhase =
renderCompleted * TAU + (renderTip ? partialFrac * TAU : 0);
// ── Helix helpers (closures over the measured geometry) ──
const ptOf = (ph: number) => ({
x: centerX + amp * Math.sin(ph),
y: baseY - (ph / TAU) * pitch,
depth: Math.cos(ph),
});
const tanOf = (ph: number) => {
const dx = amp * Math.cos(ph);
const dy = -(pitch / TAU);
const l = Math.hypot(dx, dy) || 1;
return { tx: dx / l, ty: dy / l };
};
const widthOf = (t: number) => wBase + (wTip - wBase) * t;
const ribbon = (p0: number, p1: number) => {
const steps = Math.max(2, Math.round(((p1 - p0) / TAU) * 56));
const left: string[] = [];
const right: string[] = [];
for (let i = 0; i <= steps; i++) {
const ph = p0 + ((p1 - p0) * i) / steps;
const c = ptOf(ph);
const { tx, ty } = tanOf(ph);
const nx = -ty;
const ny = tx;
const w = widthOf(topPhase > 0 ? ph / topPhase : 0) / 2;
left.push(`${(c.x + nx * w).toFixed(1)} ${(c.y + ny * w).toFixed(1)}`);
right.push(`${(c.x - nx * w).toFixed(1)} ${(c.y - ny * w).toFixed(1)}`);
}
return `M${left.join(" L")} L${right.reverse().join(" L")} Z`;
};
const centerPath = (p0: number, p1: number) => {
const steps = Math.max(2, Math.round(((p1 - p0) / TAU) * 56));
const pts: string[] = [];
for (let i = 0; i <= steps; i++) {
const c = ptOf(p0 + ((p1 - p0) * i) / steps);
pts.push(`${c.x.toFixed(1)} ${c.y.toFixed(1)}`);
}
return `M${pts.join(" L")}`;
};
const frontRuns = () => {
const runs: [number, number][] = [];
let start: number | null = null;
const step = TAU / 80;
for (let ph = 0; ph <= topPhase + 1e-6; ph += step) {
const d = Math.cos(ph);
if (d >= 0 && start === null) start = ph;
if (d < 0 && start !== null) {
runs.push([start, ph]);
start = null;
}
}
if (start !== null) runs.push([start, topPhase]);
return runs;
};
const tendrilPath = (x: number, y: number, dir: number, scale: number) => {
const loops = 2.3;
const R = 11 * scale;
const a0 = dir > 0 ? 0 : Math.PI;
const ex = x + dir * 15 * scale;
const ey = y - 5 * scale;
const steps = 56;
const pts: string[] = [`${x.toFixed(1)} ${y.toFixed(1)}`];
for (let i = 0; i <= steps; i++) {
const a = a0 + (i / steps) * loops * TAU;
const r = R * (1 - 0.62 * (i / steps));
pts.push(
`${(ex + dir * r * Math.cos(a)).toFixed(1)} ${(ey + r * Math.sin(a)).toFixed(1)}`,
);
}
return `M${pts.join(" L")}`;
};
// ── Build foliage ──
type LeafSpec = {
key: string;
x: number;
y: number;
rot: number;
L: number;
back: boolean;
delay: number;
};
const leaves: LeafSpec[] = [];
const petioles: { key: string; d: string }[] = [];
if (ready && topPhase > 0) {
let k = 0;
for (let ph = Math.PI / 2; ph < topPhase - 0.25; ph += Math.PI, k++) {
const c = ptOf(ph);
const right = Math.sin(ph) > 0;
const t = ph / topPhase;
const L = (54 - 22 * t) * (k % 3 === 0 ? 0.82 : 1);
const petLen = 11 * (1 - 0.4 * t);
const rot = (right ? 56 : -56) + (k % 2 ? 8 : -6) + (right ? pal.droop : -pal.droop);
const aa = ((rot - 90) * Math.PI) / 180;
const lx = c.x + Math.cos(aa) * petLen;
const ly = c.y + Math.sin(aa) * petLen;
petioles.push({
key: `p${k}`,
d: `M${c.x.toFixed(1)} ${c.y.toFixed(1)} q ${(Math.cos(aa) * petLen * 0.4).toFixed(1)} ${(Math.sin(aa) * petLen * 0.4).toFixed(1)} ${(Math.cos(aa) * petLen).toFixed(1)} ${(Math.sin(aa) * petLen).toFixed(1)}`,
});
leaves.push({
key: `l${k}`,
x: lx,
y: ly,
rot,
L,
back: c.depth < -0.1,
delay: (k % 5) * 0.45,
});
}
}
const backLeaves = leaves.filter((l) => l.back);
const frontLeaves = leaves.filter((l) => !l.back);
// Tendrils: always one at the growing tip, plus a couple along the way.
const tendrils: { key: string; d: string }[] = [];
if (ready && topPhase > 0) {
const t = ptOf(topPhase);
tendrils.push({
key: "t-tip",
d: tendrilPath(t.x, t.y, Math.sin(topPhase) >= 0 ? 1 : -1, 1.1),
});
for (let ph = Math.PI * 1.5; ph < topPhase - 0.4; ph += TAU) {
const c = ptOf(ph);
tendrils.push({
key: `t${ph.toFixed(0)}`,
d: tendrilPath(c.x, c.y, -1, 0.85),
});
}
}
// ── Day nodes (chips anchor here, at each fold's front-centre top) ──
type Node = {
key: string;
x: number;
y: number;
day: VineDay;
kind: "fold" | "tip";
sideRight: boolean;
};
const nodes: Node[] = [];
if (ready) {
for (let i = 0; i < renderCompleted; i++) {
const phase = (i + 1) * TAU;
const c = ptOf(phase);
nodes.push({
key: render[i].date,
x: c.x,
y: c.y,
day: render[i],
kind: "fold",
sideRight: i % 2 === 0,
});
}
if (renderTip) {
const c = ptOf(topPhase);
nodes.push({
key: `${renderTip.date}-tip`,
x: c.x,
y: c.y,
day: renderTip,
kind: "tip",
sideRight: renderCompleted % 2 === 0,
});
}
}
const filledToTop = ready && unitsNeeded >= maxWraps && renderCompleted >= 1;
const runs = ready ? frontRuns() : [];
return (
<div ref={ref} className={cn("relative w-full h-full overflow-hidden", className)}>
<style>{`
@keyframes sv-leaf { 0%,100%{transform:rotate(-5deg)} 50%{transform:rotate(5deg)} }
@keyframes sv-flag { 0%,100%{transform:scaleX(1)} 50%{transform:scaleX(0.82)} }
@keyframes sv-tip { 0%,100%{transform:rotate(-6deg)} 50%{transform:rotate(7deg)} }
.sv-leaf { transform-box:fill-box; transform-origin:50% 100%; animation:sv-leaf 4.5s ease-in-out infinite; }
.sv-flag { transform-box:fill-box; transform-origin:0% 50%; animation:sv-flag 2.6s ease-in-out infinite; }
.sv-tip { transform-box:fill-box; transform-origin:50% 100%; animation:sv-tip 3.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce){ .sv-leaf,.sv-flag,.sv-tip{ animation:none } }
`}</style>
{ready ? (
<svg
viewBox={`0 0 ${W} ${H}`}
width={W}
height={H}
className="absolute inset-0"
role="img"
aria-label={`Your sādhana vine — ${totalStreak} day streak`}
>
<defs>
<linearGradient id="sv-pole" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#caa066" />
<stop offset="0.5" stopColor="#a87a40" />
<stop offset="1" stopColor="#724d24" />
</linearGradient>
<linearGradient
id="sv-stem"
gradientUnits="userSpaceOnUse"
x1={centerX - amp}
y1="0"
x2={centerX + amp}
y2="0"
>
<stop offset="0" stopColor={pal.stemFrontTop} />
<stop offset="0.5" stopColor={pal.stemFrontMid} />
<stop offset="1" stopColor={pal.stemFrontBot} />
</linearGradient>
<linearGradient id="sv-leaf-front" x1="-0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor={pal.leafFront[0]} />
<stop offset="0.5" stopColor={pal.leafFront[1]} />
<stop offset="1" stopColor={pal.leafFront[2]} />
</linearGradient>
<linearGradient id="sv-leaf-back" x1="-0.1" y1="0" x2="0.3" y2="1">
<stop offset="0" stopColor={pal.leafBack[0]} />
<stop offset="0.5" stopColor={pal.leafBack[1]} />
<stop offset="1" stopColor={pal.leafBack[2]} />
</linearGradient>
<linearGradient id="sv-soil" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#9c7350" />
<stop offset="1" stopColor="#6b4a31" />
</linearGradient>
</defs>
{/* Back-facing leaves */}
{backLeaves.map((lf) => (
<Leaf key={lf.key} lf={lf} pal={pal} fill="url(#sv-leaf-back)" />
))}
{/* Vine far half — behind the pole */}
{topPhase > 0 ? <path d={ribbon(0, topPhase)} fill={pal.stemBack} /> : null}
{/* Pole */}
<rect
x={centerX - poleW / 2}
y={topPad}
width={poleW}
height={baseY - topPad}
rx={poleW / 2}
fill="url(#sv-pole)"
/>
<rect
x={centerX - poleW / 2 + 1.5}
y={topPad}
width={1.8}
height={baseY - topPad}
rx={1}
fill="#ecd6ad"
opacity={0.5}
/>
{/* bamboo node rings */}
{Array.from({ length: Math.max(2, Math.floor((baseY - topPad) / 90)) }).map(
(_, i) => {
const y = baseY - (i + 1) * 90;
if (y < topPad + 8) return null;
return (
<rect
key={`ring${i}`}
x={centerX - poleW / 2 - 1}
y={y}
width={poleW + 2}
height={3}
rx={1.5}
fill="#6b4a26"
opacity={0.45}
/>
);
},
)}
{/* Finial + saffron flag */}
<circle cx={centerX} cy={topPad} r={poleW * 0.55} fill="#b8842f" />
<circle cx={centerX - 1} cy={topPad - 1} r={poleW * 0.22} fill="#f0d9a8" opacity={0.6} />
{filledToTop ? (
<g className="sv-flag">
<path
d={`M${centerX + poleW * 0.4} ${topPad - 2}
L${centerX + poleW * 0.4 + 32} ${topPad + 5}
L${centerX + poleW * 0.4} ${topPad + 12} Z`}
fill="#ef8a35"
/>
</g>
) : null}
{/* Vine near half — over the pole, with a tube sheen */}
{runs.map(([a, b], i) => (
<path key={`fr${i}`} d={ribbon(a, b)} fill="url(#sv-stem)" />
))}
{runs.map(([a, b], i) => (
<path
key={`sh${i}`}
d={centerPath(a, b)}
fill="none"
stroke={pal.sheen}
strokeWidth={Math.max(2, wBase * 0.22)}
strokeLinecap="round"
opacity={pal.dim ? 0.25 : 0.45}
/>
))}
{/* Soil */}
<ellipse cx={centerX} cy={baseY + 8} rx={Math.min(70, W * 0.3)} ry={12} fill="url(#sv-soil)" />
<ellipse cx={centerX} cy={baseY + 3} rx={Math.min(58, W * 0.25)} ry={6} fill="#9c7350" />
{/* Tendrils */}
{tendrils.map((td) => (
<path
key={td.key}
className="sv-tip"
d={td.d}
fill="none"
stroke={pal.tendril}
strokeWidth={2.2}
strokeLinecap="round"
/>
))}
{/* Petioles + front leaves */}
{petioles.map((p) => (
<path
key={p.key}
d={p.d}
fill="none"
stroke={pal.stemFrontMid}
strokeWidth={2.4}
strokeLinecap="round"
/>
))}
{frontLeaves.map((lf) => (
<Leaf key={lf.key} lf={lf} pal={pal} fill="url(#sv-leaf-front)" />
))}
</svg>
) : null}
{/* Day chips (HTML overlay, aligned to the SVG nodes) */}
{ready
? nodes.map((n) => (
<DayChip
key={`c-${n.key}`}
x={n.x}
y={n.y}
W={W}
sideRight={n.sideRight}
day={n.day}
isTip={n.kind === "tip"}
dim={pal.dim}
/>
))
: null}
{/* Hidden-folds hint at the base */}
{ready && hidden > 0 ? (
<div
className="absolute left-1/2 -translate-x-1/2 rounded-full border border-border bg-card/90 px-2.5 py-0.5 text-[10px] text-muted-foreground shadow-sm"
style={{ bottom: 6 }}
>
+{hidden} earlier {hidden === 1 ? "day" : "days"}
</div>
) : null}
</div>
);
}
function Leaf({
lf,
pal,
fill,
}: {
lf: { x: number; y: number; rot: number; L: number; delay: number };
pal: Palette;
fill: string;
}) {
return (
<g
className="sv-leaf"
style={{ animationDelay: `${lf.delay}s` }}
transform={`translate(${lf.x.toFixed(1)} ${lf.y.toFixed(1)}) rotate(${lf.rot.toFixed(1)})`}
>
<path d={leafOutline(lf.L)} fill={fill} stroke={pal.vein} strokeWidth={0.7} />
<path
d={leafVeins(lf.L)}
fill="none"
stroke={pal.vein}
strokeWidth={Math.max(0.5, lf.L * 0.013)}
strokeLinecap="round"
opacity={0.45}
/>
<path
d={`M ${-lf.L * 0.1} ${-lf.L * 0.3} Q ${-lf.L * 0.28} ${-lf.L * 0.55} ${-lf.L * 0.12} ${-lf.L * 0.8}`}
fill="none"
stroke="#d8f2b0"
strokeWidth={lf.L * 0.045}
strokeLinecap="round"
opacity={pal.dim ? 0.12 : 0.25}
/>
</g>
);
}
function DayChip({
x,
y,
W,
sideRight,
day,
isTip,
dim,
}: {
x: number;
y: number;
W: number;
sideRight: boolean;
day: VineDay;
isTip: boolean;
dim: boolean;
}) {
const gap = 18;
const style: React.CSSProperties = sideRight
? { left: x + gap, top: y, transform: "translateY(-50%)" }
: { right: W - x + gap, top: y, transform: "translateY(-50%)" };
const blank = isTip && day.rounds === 0 && day.posts === 0;
return (
<div
className={cn(
"pointer-events-none absolute flex max-w-[42%] flex-col gap-0.5 rounded-xl border bg-card/90 px-2 py-1 shadow-sm backdrop-blur-sm",
isTip ? "border-primary/50 ring-1 ring-primary/20" : "border-border",
dim && !isTip && "opacity-70",
)}
style={style}
>
<span className="text-[10px] font-medium leading-none text-muted-foreground">
{isTip ? "Today" : fmtDate(day.date)}
</span>
{blank ? (
<span className="flex items-center gap-1 text-[11px] font-medium leading-none text-primary">
<Sparkles className="size-3" /> Begin
</span>
) : (
<span className="flex items-center gap-2 text-[11px] font-semibold leading-none text-foreground tabular-nums">
<span className="flex items-center gap-0.5" title="rounds chanted">
<CircleDot className="size-3 text-primary" />
{day.rounds}
</span>
<span className="flex items-center gap-0.5" title="quotes posted">
<MessageSquareQuote className="size-3 text-primary" />
{day.posts}
</span>
</span>
)}
</div>
);
}
function fmtDate(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
+366
View File
@@ -0,0 +1,366 @@
"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. "Today" is the browser's
* local date, matching the backend's local-timezone day boundary.
*/
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";
// Each calendar day is represented as the UTC-midnight epoch-ms of that date
// (so the date string and all arithmetic stay tz-free and internally
// consistent). "Today", however, is anchored to the browser's LOCAL date so
// the day boundary matches the backend's local-timezone day reset.
function ymd(ms: number): string {
return new Date(ms).toISOString().slice(0, 10);
}
// UTC-midnight ms whose calendar date is the browser's local today.
function localTodayMs(): number {
const d = new Date();
return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
}
function startOfWeek(ms: number): number {
return ms - new Date(ms).getUTCDay() * DAY;
}
export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) {
const todayMs = localTodayMs();
const todayStr = ymd(todayMs);
const [view, setView] = useState<View>("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<Map<string, Map<string, SadhnaDay>>>(new Map());
const reqRef = useRef(0);
const [byDate, setByDate] = useState<Map<string, SadhnaDay>>(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 (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
{/* Streak + view toggle */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className={cn(
"inline-flex size-10 items-center justify-center rounded-full",
streak > 0 ? "bg-orange-500/15" : "bg-muted",
)}
>
<StreakFlame className="size-6" muted={streak === 0} />
</span>
<div className="leading-tight">
<p className="text-lg font-semibold text-foreground tabular-nums">
{streak} day{streak === 1 ? "" : "s"}
</p>
<p className="text-xs text-muted-foreground">
{streak > 0 ? "current streak" : "start your streak today"}
{longest > 0 ? ` · best ${longest}` : ""}
</p>
</div>
</div>
<div className="inline-flex rounded-lg border border-border p-0.5 text-xs">
{(["week", "month"] as View[]).map((v) => (
<button
key={v}
type="button"
onClick={() => 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}
</button>
))}
</div>
</div>
{/* Period nav */}
<div className="mb-3 flex items-center justify-between">
<NavButton label="Previous" onClick={() => shift(-1)}>
<ChevronLeft className="size-4" />
</NavButton>
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm font-medium text-foreground",
loading && "opacity-60",
)}
>
{view === "week"
? weekRangeLabel(grid.start)
: monthLabel(anchorMs)}
</span>
{!atCurrent ? (
<button
type="button"
onClick={() => setAnchorMs(todayMs)}
className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
>
Today
</button>
) : null}
</div>
<NavButton label="Next" onClick={() => shift(1)} disabled={atCurrent}>
<ChevronRight className="size-4" />
</NavButton>
</div>
{/* Weekday header (month view) */}
{view === "month" ? (
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS.map((w) => (
<span
key={w}
className="text-center text-[11px] leading-none text-muted-foreground"
>
{w}
</span>
))}
</div>
) : null}
<div className={cn("grid grid-cols-7 gap-1", loading && "opacity-70")}>
{cells.map((ms) => {
const date = ymd(ms);
return (
<DayCell
key={date}
date={date}
day={dayFor(date)}
view={view}
isToday={date === todayStr}
isFuture={ms > todayMs}
isGift={!!giftDate && date === giftDate}
inMonth={view === "week" || new Date(ms).getUTCMonth() === grid.monthIndex}
/>
);
})}
</div>
</section>
);
}
function NavButton({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"disabled:pointer-events-none disabled:opacity-30",
)}
>
{children}
</button>
);
}
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 (
<div
className={cn(
"flex flex-col items-center gap-1.5",
!inMonth && "opacity-35",
)}
>
{isWeek ? (
<span
className={cn(
"text-[11px] leading-none",
isToday ? "font-semibold text-primary" : "text-muted-foreground",
)}
>
{WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]}
</span>
) : null}
<div
className={cn(
"flex items-center justify-center rounded-full font-medium transition-colors",
isWeek ? "size-9 text-sm sm:size-10" : "size-8 text-xs",
completed && "bg-primary text-primary-foreground",
!completed && partial && "bg-primary/15 text-foreground ring-1 ring-primary/40",
!completed && !partial && isToday && "text-foreground ring-2 ring-primary",
!completed && !partial && !isToday && isFuture && "text-muted-foreground/40",
!completed && !partial && !isToday && !isFuture && "bg-muted text-muted-foreground",
)}
>
{dayOfMonth(date)}
</div>
{/* Marker slot — fixed height keeps every column's baseline aligned. */}
<div
className={cn(
"flex items-center justify-center",
isWeek ? "h-5" : "h-3.5",
)}
>
{completed ? (
<Check
className={cn("text-primary", isWeek ? "size-4" : "size-3")}
strokeWidth={3}
/>
) : isGift ? (
<Gift
className={cn("text-amber-500", isWeek ? "size-4" : "size-3")}
/>
) : null}
</div>
</div>
);
}
// ── 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()}`;
}
+82
View File
@@ -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<SadhnaDashboard | null>(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 (
<Link
href="/sadhna-streak"
className="group flex w-full max-w-md items-center gap-3.5 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
>
<span
className={
"inline-flex size-12 shrink-0 items-center justify-center rounded-full " +
(streak > 0 ? "bg-orange-500/15" : "bg-muted")
}
>
<StreakFlame className="size-7" muted={streak === 0} />
</span>
<div className="min-w-0 flex-1">
<p className="text-base font-semibold text-foreground leading-snug">
{title}
</p>
<p className="truncate text-sm text-muted-foreground leading-snug">
{subtitle}
</p>
</div>
<ChevronRight className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" />
</Link>
);
}
+41
View File
@@ -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 (
<svg viewBox="0 0 24 24" className={className} role="img" aria-label="Streak">
<defs>
<linearGradient id="streak-flame-outer" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={outer[0]} />
<stop offset="0.55" stopColor={outer[1]} />
<stop offset="1" stopColor={outer[2]} />
</linearGradient>
<linearGradient id="streak-flame-inner" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={inner[0]} />
<stop offset="1" stopColor={inner[1]} />
</linearGradient>
</defs>
<path
fill="url(#streak-flame-outer)"
d="M13.6 1.8c0.2 2.6 2 4.1 3.4 5.9c1.5 1.9 2.5 4 2.5 6.3c0 4.2-3.4 7.2-7.5 7.2c-4.1 0-7.5-3-7.5-7.2c0-2.1 0.9-3.9 2.3-5.3c0.1 1 0.6 1.9 1.4 2.5c-0.4-2.6 0.5-5.2 2.3-7c1-1 2.3-1.8 3.1-3.1c0.2-0.4 0.4-0.9 0.5-1.3z"
/>
<path
fill="url(#streak-flame-inner)"
d="M12.4 9.6c0.7 1 1.9 1.9 2.6 3c0.6 0.9 0.9 1.9 0.9 2.9c0 2.2-1.8 3.8-3.9 3.8c-2.1 0-3.7-1.6-3.7-3.6c0-1.6 0.9-2.7 1.9-3.7c0.1 0.6 0.4 1.1 0.9 1.5c-0.2-1.4 0.2-2.6 1-3.4c0.2-0.2 0.3-0.3 0.4-0.5z"
/>
</svg>
);
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -119,7 +119,7 @@ export function CollectionScrollCard({
const showArrows = slideCount > 1;
return (
<article ref={cardRef} className="space-y-3">
<article ref={cardRef} className="space-y-3 select-none">
<div className="relative max-w-xl mx-auto">
<div
ref={scrollRef}
+4 -4
View File
@@ -208,7 +208,7 @@ export function EditCollectionDrawer({
: "max-w-md sm:max-w-lg"
}
>
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2">
<DrawerHeader className="shrink-0 text-left flex items-center justify-between pb-2 select-none">
<DrawerTitle>Edit collection</DrawerTitle>
<DrawerClose asChild>
<button
@@ -222,7 +222,7 @@ export function EditCollectionDrawer({
</DrawerClose>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5">
<div className="flex-1 min-h-0 overflow-y-auto px-4 pb-4 space-y-5 select-none">
{/* Cover */}
<section className="flex items-start gap-4">
<div className="relative size-24 rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 shrink-0">
@@ -283,7 +283,7 @@ export function EditCollectionDrawer({
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
className="w-full bg-background text-foreground text-base rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 select-text"
/>
</section>
@@ -297,7 +297,7 @@ export function EditCollectionDrawer({
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="Optional"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 resize-none select-text"
/>
</section>
+3 -2
View File
@@ -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();
};
}, []);
@@ -195,7 +196,7 @@ export function PageBookmarks() {
left: m.left,
zIndex: 5,
}}
className="pointer-events-none"
className="pointer-events-none select-none"
>
<div className="pointer-events-auto relative inline-flex items-center gap-1 rounded-full bg-violet-500 shadow-lg px-2.5 py-1 animate-in fade-in-0 zoom-in-95">
<Bookmark className="size-3.5 text-white" />
+58 -7
View File
@@ -21,9 +21,10 @@
* see the in-place auth modal before the underlying action runs.
*/
import { useState } from "react";
import { useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import {
HiHeart,
HiOutlineArrowPath,
HiOutlineChatBubbleOvalLeft,
HiOutlineFolderPlus,
@@ -37,6 +38,7 @@ import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
import { PostLikesDrawer } from "@/components/social/post-likes-drawer";
import { useSharePost } from "./share-post-provider";
// All icons come from the Heroicons v2 outline family (react-icons
@@ -60,6 +62,12 @@ export function PostActionsFooter({ post }: { post: Post }) {
const [reshared, setReshared] = useState<boolean>(
!!post.viewer_has_reshared,
);
// Like state — seeded from the hydrated post, toggled optimistically and
// reconciled to the server's effective count on success.
const [liked, setLiked] = useState<boolean>(!!post.viewer_has_liked);
const [likeCount, setLikeCount] = useState<number>(post.likes_count ?? 0);
const [likesOpen, setLikesOpen] = useState(false);
const likeBusy = useRef(false);
const isOwner = !!user && user.id === post.user_id;
// Owners and resharers both see Send — the post is "on their account"
@@ -71,6 +79,32 @@ export function PostActionsFooter({ post }: { post: Post }) {
await fn();
}
// Like toggle — optimistic flip + count nudge, reconciled to the server's
// effective total (DB count + pending Redis delta) on success, rolled back
// on error. A ref guard ignores taps while a request is in flight.
async function onLikeToggle() {
if (likeBusy.current) return;
likeBusy.current = true;
const next = !liked;
setLiked(next);
setLikeCount((c) => Math.max(0, c + (next ? 1 : -1)));
try {
const res = next
? await postsApi.like(post.id)
: await postsApi.unlike(post.id);
setLiked(res.liked);
setLikeCount(res.likes_count);
} catch (err) {
setLiked(!next);
setLikeCount((c) => Math.max(0, c + (next ? -1 : 1)));
const msg =
err instanceof SocialError ? err.message : "Could not update like";
toast.error(msg, { duration: 3000 });
} finally {
likeBusy.current = false;
}
}
async function onReshare() {
// Optimistic flip first so the UI feels instant; revert on error.
setReshared(true);
@@ -105,14 +139,26 @@ export function PostActionsFooter({ post }: { post: Post }) {
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-1">
<ActionButton
label="Like"
onClick={() =>
withAuth(() => {
toast("Likes are coming soon", { duration: 2000 });
})
label={liked ? "Unlike" : "Like"}
onClick={() => withAuth(onLikeToggle)}
icon={
liked ? (
<HiHeart className={cn(ICON_CLASS, "text-red-500")} />
) : (
<HiOutlineHeart className={ICON_CLASS} />
)
}
icon={<HiOutlineHeart className={ICON_CLASS} />}
/>
{likeCount > 0 ? (
<button
type="button"
onClick={() => setLikesOpen(true)}
aria-label={`${likeCount} ${likeCount === 1 ? "like" : "likes"}`}
className="-ml-1 mr-1 rounded px-1 text-sm font-medium tabular-nums text-muted-foreground transition-colors hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{likeCount}
</button>
) : null}
<ActionButton
label="Comment"
onClick={() =>
@@ -169,6 +215,11 @@ export function PostActionsFooter({ post }: { post: Post }) {
isMobile={isMobile}
existingPostId={post.id}
/>
<PostLikesDrawer
open={likesOpen}
postId={post.id}
onClose={() => setLikesOpen(false)}
/>
{/* SharePostSheet is mounted globally by SharePostProvider, so
no per-card instance lives here. The provider's
openShareSheet swaps the active post under the hood. */}
+69 -29
View File
@@ -19,9 +19,9 @@
* elsewhere in the post action UI.
*/
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Ban, Flag, MoreVertical, Trash2 } from "lucide-react";
import { Ban, Copy, Flag, MoreVertical, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
@@ -41,6 +41,8 @@ import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { ReportDialog, type ReportOption } from "@/components/social/report-dialog";
import { createShortLink } from "@/lib/share";
import { structuredReferenceForPost } from "@/lib/source-reference";
// Post report categories — mirrors the BFF allow-list (postReportCategories).
const POST_REPORT_OPTIONS: ReportOption[] = [
@@ -90,37 +92,35 @@ export function PostCardMenu({
: post.author_name || "this user";
// Desktop popover positioning: anchor below the trigger, right-aligned.
// Recompute every time we open + on resize so a viewport change between
// renders doesn't strand the popover at a stale coordinate.
// We measure in event handlers (the open click + resize/scroll), never
// synchronously inside an effect body, so opening can't trigger a
// cascading render. The effect below only wires the resize/scroll
// listeners while the popover is open.
const recompute = useCallback(() => {
const btn = triggerRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const popoverW = 240;
const pad = 8;
// Right-align the popover to the trigger's right edge — feels natural
// for an overflow menu sitting in the top-right corner.
let left = rect.right - popoverW;
if (left < pad) left = pad;
if (left + popoverW > window.innerWidth - pad) {
left = window.innerWidth - popoverW - pad;
}
setPopPos({ top: rect.bottom + 4, left });
}, []);
useEffect(() => {
if (!open || isMobile) {
setPopPos(null);
return;
}
function recompute() {
const btn = triggerRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const popoverW = 240;
const pad = 8;
// Right-align the popover to the trigger's right edge — feels
// natural for an overflow menu sitting in the top-right corner.
let left = rect.right - popoverW;
if (left < pad) left = pad;
if (left + popoverW > window.innerWidth - pad) {
left = window.innerWidth - popoverW - pad;
}
const top = rect.bottom + 4;
setPopPos({ top, left });
}
recompute();
if (!open || isMobile) return;
window.addEventListener("resize", recompute);
window.addEventListener("scroll", recompute, true);
return () => {
window.removeEventListener("resize", recompute);
window.removeEventListener("scroll", recompute, true);
};
}, [open, isMobile]);
}, [open, isMobile, recompute]);
// Click-outside + Escape to close (desktop only; the drawer's own
// overlay handles dismissal on mobile).
@@ -149,10 +149,45 @@ export function PostCardMenu({
};
}, [open, isMobile]);
// Copy the post's caption + source citation + a (short) link to the post —
// the same payload the share sheet / footer produce. Available to every
// viewer.
async function copyCaption() {
setOpen(false);
try {
const origin = window.location.origin;
let url = "";
if (authorHandle) {
const longPath = `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`;
const code = await createShortLink(longPath);
url = code
? new URL(`/share/${code}`, origin).toString()
: new URL(longPath, origin).toString();
}
const ref = structuredReferenceForPost(post.source_url, post.source_title);
const lines: string[] = [];
if (post.caption) lines.push(`“${post.caption}”`, "");
lines.push("Source:", ref.title);
if (ref.detail) lines.push(ref.detail);
if (url) lines.push(url);
await navigator.clipboard.writeText(lines.join("\n"));
toast.success("Caption copied");
} catch {
toast.error("Could not copy");
}
}
// Build the items list AFTER all hooks have run so a conditional
// early-return doesn't break the rules-of-hooks contract on
// re-renders where viewer state changes.
const items: MenuItem[] = [];
// Copy caption — first item for everyone (owner and non-owner alike).
items.push({
key: "copy",
label: "Copy caption",
icon: <Copy className="size-4" />,
onClick: copyCaption,
});
if (isOwner) {
if (onDelete) {
items.push({
@@ -202,7 +237,12 @@ export function PostCardMenu({
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
onClick={() => {
// Measure synchronously in the click (an event handler — safe) so
// the popover renders at the right spot on first paint.
if (!open && !isMobile) recompute();
setOpen((v) => !v);
}}
aria-label="Post options"
aria-haspopup="menu"
aria-expanded={open}
@@ -219,7 +259,7 @@ export function PostCardMenu({
// Menu surface: bottom drawer on mobile, floating popover on desktop.
const menu = isMobile ? (
<Drawer open={open} onOpenChange={(o) => setOpen(o)}>
<DrawerContent className="bg-card">
<DrawerContent className="bg-card select-none">
<DrawerHeader className="text-left border-b border-border pb-3">
<DrawerTitle>Post options</DrawerTitle>
</DrawerHeader>
@@ -242,7 +282,7 @@ export function PostCardMenu({
zIndex: 60,
}}
className={cn(
"w-60 rounded-lg border border-border bg-card shadow-lg py-1",
"w-60 rounded-lg border border-border bg-card shadow-lg py-1 select-none",
"animate-in fade-in-0 zoom-in-95",
)}
>
+1 -1
View File
@@ -45,7 +45,7 @@ export function PostCard({
const authorHandle = post.author_handle ?? null;
return (
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5 select-none">
{/* Reshare attribution — only on rows that are themselves
reshares. The link goes to the original author's profile;
their handle is rendered inline so the source is obvious.
+29
View File
@@ -0,0 +1,29 @@
"use client";
/**
* PostLikesDrawer — the "liked by" list for a post. A thin wrapper over the
* generic virtualized UserListDrawer, wired to the post-likes endpoint.
*/
import { posts } from "@/lib/social";
import { UserListDrawer } from "@/components/social/user-list-drawer";
export function PostLikesDrawer({
open,
postId,
onClose,
}: {
open: boolean;
postId: string;
onClose: () => void;
}) {
return (
<UserListDrawer
open={open}
onClose={onClose}
title="Likes"
depsKey={`likes:${postId}`}
fetchPage={(params) => posts.likers(postId, params)}
/>
);
}
+1 -1
View File
@@ -73,7 +73,7 @@ export function PostScrollCard({
return (
<article
id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24 select-none"
>
{/* Reshare attribution — only on rows that are themselves
reshares. Tappable link → original author's profile. The card
+3 -3
View File
@@ -125,7 +125,7 @@ export function ReportDialog({
rows={3}
maxLength={2000}
placeholder="Add any context that will help us review this"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
className="w-full bg-background text-foreground text-sm rounded-md border border-border px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary/40 select-text"
/>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
@@ -144,7 +144,7 @@ export function ReportDialog({
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && !working && onClose()}>
<DrawerContent className="bg-card">
<DrawerContent className="bg-card select-none">
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
@@ -168,7 +168,7 @@ export function ReportDialog({
aria-modal="true"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden select-none">
<div className="flex items-start gap-3 px-5 pt-5 pb-1">
<div className="flex-1 min-w-0">
<h2 className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground">
+1 -1
View File
@@ -445,7 +445,7 @@ export function SharePostSheet({
onOpenChange={(o) => !o && onClose()}
direction={direction}
>
<DrawerContent direction={direction} className="bg-card">
<DrawerContent direction={direction} className="bg-card select-none">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle>Share post</DrawerTitle>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
@@ -202,7 +202,7 @@ export function UpdateBackgroundModal({
aria-labelledby="update-bg-title"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden select-none">
<div className="flex items-start gap-3 px-5 pt-5 pb-3">
<span
className="shrink-0 size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
@@ -218,8 +218,8 @@ export function UpdateBackgroundModal({
Update Background
</h2>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
Pick an image to set as the card's background, or remove the
current one.
Pick an image to set as the card&apos;s background, or remove
the current one.
</p>
</div>
<button
+274
View File
@@ -0,0 +1,274 @@
"use client";
/**
* UserListDrawer — a generic, virtualized, paginated, searchable list of
* users in a drawer (right on desktop, bottom sheet on mobile). Powers both
* the followers/following lists and the post "liked by" list.
*
* The caller supplies `fetchPage(params)` returning `{ users, total }` and a
* `depsKey` that changes when the data source changes (so the list reloads).
* Search is debounced and queries the backend across the full set. The list
* loads the next page as you scroll near the bottom.
*
* State-update notes (set-state-in-effect rule): the (re)load runs inside a
* debounce timer callback and pagination runs from the scroll handler — never
* synchronously inside an effect body.
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { Loader2, Search } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import type { FollowList, FollowListParams, UserSummary } from "@/lib/social";
import { cn } from "@/lib/utils";
const PAGE = 30;
const ROW = 64; // px — fixed row height for the virtualizer
export type FetchUserPage = (params: FollowListParams) => Promise<FollowList>;
export function UserListDrawer({
open,
onClose,
title,
depsKey,
fetchPage,
}: {
open: boolean;
onClose: () => void;
title: string;
/** Changes when the data source changes (e.g. mode/handle or post id), to
* trigger a fresh reload. */
depsKey: string;
fetchPage: FetchUserPage;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const direction = isMobile ? "bottom" : "right";
const [q, setQ] = useState("");
const [items, setItems] = useState<UserSummary[]>([]);
const [total, setTotal] = useState(0);
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
// reqRef tags the current query "session"; a newer reload bumps it so any
// in-flight reload/append for an older query is ignored on arrival.
const reqRef = useRef(0);
const busyRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
// Latest fetchPage, read via ref so its (unstable) identity isn't an effect
// dep — the reload keys on `depsKey` instead.
const fetchRef = useRef(fetchPage);
fetchRef.current = fetchPage;
// (Re)load page 0 when the drawer opens or the source/query changes.
// Debounced so typing in search doesn't spam the backend.
useEffect(() => {
if (!open) return;
const t = window.setTimeout(() => {
const token = ++reqRef.current;
busyRef.current = true;
setItems([]);
setStatus("loading");
fetchRef
.current({ q, limit: PAGE, offset: 0 })
.then((res) => {
if (reqRef.current !== token) return;
setItems(res.users);
setTotal(res.total);
setStatus("idle");
})
.catch(() => {
if (reqRef.current === token) setStatus("error");
})
.finally(() => {
if (reqRef.current === token) busyRef.current = false;
});
}, 250);
return () => window.clearTimeout(t);
}, [open, depsKey, q]);
function loadMore() {
if (busyRef.current || items.length >= total) return;
const token = reqRef.current; // same session — don't bump
const offset = items.length;
busyRef.current = true;
setStatus("loading");
fetchRef
.current({ q, limit: PAGE, offset })
.then((res) => {
if (reqRef.current !== token) return;
setItems((prev) => [...prev, ...res.users]);
setTotal(res.total);
setStatus("idle");
})
.catch(() => {
if (reqRef.current === token) setStatus("error");
})
.finally(() => {
if (reqRef.current === token) busyRef.current = false;
});
}
// TanStack Virtual returns non-memoizable functions; the React Compiler
// simply skips memoizing this component, which is fine here.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW,
overscan: 8,
});
const empty = status !== "loading" && items.length === 0;
return (
<Drawer
open={open}
direction={direction}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<DrawerContent
direction={direction}
className={cn(
"bg-card flex flex-col",
isMobile ? "h-[85vh]" : "h-full w-[88vw] max-w-sm",
)}
>
<DrawerHeader className="border-b border-border text-left">
<DrawerTitle className="text-base">{title}</DrawerTitle>
<div className="relative mt-2">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search"
autoComplete="off"
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</DrawerHeader>
<div
ref={scrollRef}
onScroll={(e) => {
const el = e.currentTarget;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 240) {
loadMore();
}
}}
className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
>
{status === "error" ? (
<Message>Couldn&apos;t load. Please try again.</Message>
) : empty ? (
<Message>{q.trim() ? "No matches." : "Nothing here yet."}</Message>
) : (
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{virtualizer.getVirtualItems().map((vi) => {
const u = items[vi.index];
if (!u) return null;
return (
<div
key={vi.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: vi.size,
transform: `translateY(${vi.start}px)`,
}}
>
<Row user={u} onNavigate={onClose} />
</div>
);
})}
</div>
)}
{status === "loading" ? (
<div className="flex justify-center py-4">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
</DrawerContent>
</Drawer>
);
}
function Row({
user,
onNavigate,
}: {
user: UserSummary;
onNavigate: () => void;
}) {
return (
<Link
href={`/u/${user.handle}`}
onClick={onNavigate}
className="flex h-16 items-center gap-3 px-4 transition-colors hover:bg-muted/50"
>
<RowAvatar src={user.avatar_url} name={user.name} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">
{user.name}
</p>
<p className="truncate text-sm text-muted-foreground">@{user.handle}</p>
</div>
</Link>
);
}
function RowAvatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={40}
height={40}
className="size-10 shrink-0 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-semibold text-primary ring-1 ring-foreground/10">
{initials}
</div>
);
}
function Message({ children }: { children: React.ReactNode }) {
return (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
{children}
</p>
);
}
+3 -4
View File
@@ -54,10 +54,9 @@ export function TextSelectionToolbar() {
// after saving the user's last allowed bookmark.
const [bookmarkCount, setBookmarkCount] = useState(0);
useEffect(() => {
if (!user) {
setBookmarkCount(0);
return;
}
// `limitReached` below already guards on `!!user`, so a stale count
// while signed out is harmless — no need to reset it here.
if (!user) return;
let cancelled = false;
const refetch = () => {
bookmarks
+1 -1
View File
@@ -5,7 +5,7 @@
* directly. The BFF handles sanitization, pagination, and caching.
*/
import { bff, bffList, bffOrThrow } from "@/lib/bff";
import { bff, bffList } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types
-1
View File
@@ -12,7 +12,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
-1
View File
@@ -9,7 +9,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
-1
View File
@@ -12,7 +12,6 @@ import {
getBookMeta,
getIntroSegments,
getIntroSegment,
getChapter,
getContent,
getSidebarItems,
type BookMeta,
+168
View File
@@ -0,0 +1,168 @@
/**
* Web Push client helpers.
*
* Flow: read the VAPID public key from the BFF → register the service worker
* → request notification permission → subscribe via PushManager → send the
* subscription to the BFF (which stores it and pushes to it on each
* notification). Disabling unsubscribes locally and on the server.
*
* All BFF calls ride the auth cookie (credentials: "include"), matching the
* other lib clients.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
/** True when the browser can do Web Push at all. */
export function pushSupported(): boolean {
return (
typeof window !== "undefined" &&
"serviceWorker" in navigator &&
"PushManager" in window &&
"Notification" in window
);
}
/** iPhone/iPad/iPod (incl. iPadOS, which reports as "Macintosh" + touch). */
export function isIOS(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
return (
/iPad|iPhone|iPod/.test(ua) ||
(navigator.maxTouchPoints > 1 && /Macintosh/.test(ua))
);
}
/** Running as an installed (Home Screen) app, not a browser tab. */
export function isStandalone(): boolean {
if (typeof window === "undefined") return false;
return (
// iOS Safari's non-standard flag…
(navigator as Navigator & { standalone?: boolean }).standalone === true ||
// …and the standard display-mode for everyone else.
window.matchMedia("(display-mode: standalone)").matches
);
}
/**
* iOS only allows Web Push when the site has been added to the Home Screen
* (iOS 16.4+). In a plain Safari tab the push APIs don't exist, so this is
* the case where we should guide the user to "Add to Home Screen" rather
* than show a dead "unsupported".
*/
export function needsIOSInstall(): boolean {
return isIOS() && !isStandalone() && !pushSupported();
}
/** The browser's current notification permission, or "unsupported". */
export function pushPermission(): NotificationPermission | "unsupported" {
if (!pushSupported()) return "unsupported";
return Notification.permission;
}
type VapidKey = { public_key: string; enabled: boolean };
async function getVapidKey(): Promise<VapidKey> {
const res = await fetch(`${BFF_BASE}/api/v1/push/key`, {
credentials: "include",
});
if (!res.ok) throw new Error("Could not load push configuration");
const body = (await res.json()) as { data?: VapidKey };
return body.data ?? { public_key: "", enabled: false };
}
async function registration(): Promise<ServiceWorkerRegistration | null> {
if (!pushSupported()) return null;
return (await navigator.serviceWorker.getRegistration()) ?? null;
}
/** Whether push is currently enabled (permission granted + a live sub). */
export async function isPushEnabled(): Promise<boolean> {
if (!pushSupported() || Notification.permission !== "granted") return false;
const reg = await registration();
if (!reg) return false;
return !!(await reg.pushManager.getSubscription());
}
/** Whether the server has push configured (VAPID keys present). */
export async function pushConfigured(): Promise<boolean> {
try {
return (await getVapidKey()).enabled;
} catch {
return false;
}
}
/**
* Enable push: requests permission, subscribes, and registers the
* subscription with the BFF. Throws a user-facing message on failure.
*/
export async function enablePush(): Promise<void> {
if (!pushSupported()) {
throw new Error("This browser doesn't support push notifications.");
}
const { public_key, enabled } = await getVapidKey();
if (!enabled || !public_key) {
throw new Error("Push notifications aren't available right now.");
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error("Notification permission was not granted.");
}
const reg = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
});
}
const json = sub.toJSON() as {
endpoint?: string;
keys?: { p256dh?: string; auth?: string };
};
const res = await fetch(`${BFF_BASE}/api/v1/push/subscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }),
});
if (!res.ok) throw new Error("Could not save your subscription.");
}
/** Disable push: unsubscribe locally and remove the subscription server-side. */
export async function disablePush(): Promise<void> {
const reg = await registration();
const sub = reg ? await reg.pushManager.getSubscription() : null;
if (!sub) return;
const endpoint = sub.endpoint;
try {
await sub.unsubscribe();
} catch {
/* ignore */
}
await fetch(`${BFF_BASE}/api/v1/push/unsubscribe`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint }),
}).catch(() => {
/* best-effort */
});
}
/** VAPID public keys are base64url; PushManager wants a Uint8Array. */
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalized);
// Back with a concrete ArrayBuffer so the type is Uint8Array<ArrayBuffer>,
// which PushManager.subscribe's applicationServerKey requires.
const out = new Uint8Array(new ArrayBuffer(raw.length));
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Browser-side BFF client for the sādhana streak (/sadhna-streak).
*
* Mirrors lib/social.ts: same `/api/v1` base, same `credentials: "include"`
* so the auth cookie rides along, same envelope unwrapping. Kept in its
* own module so the streak feature doesn't bloat the social client.
*/
const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080";
export class SadhnaError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "SadhnaError";
}
}
type Envelope<T> = {
success: boolean;
data?: T;
error?: { code: number; message: string };
};
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BFF_BASE}/api/v1${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
if (res.status === 204) return undefined as T;
const body = (await res.json().catch(() => ({}))) as Envelope<T>;
if (!res.ok || body.success === false) {
throw new SadhnaError(res.status, body.error?.message || res.statusText);
}
return (body.data ?? (undefined as unknown as T)) as T;
}
// ─── Types ───────────────────────────────────────────────────────────────
/** Plant growth stage, mirrors the BFF's stageFor(). */
export type SadhnaStage =
| "dead"
| "sprout"
| "seedling"
| "sapling"
| "young"
| "mature";
/** One cell of the 7-day streak strip. */
export type SadhnaDay = {
date: string; // YYYY-MM-DD (UTC)
chant_done: boolean;
quote_done: boolean;
completed: boolean;
};
/**
* One fold of the sādhana creeper — a single day the vine has wound around
* its pole. The vine grows a fresh fold for each day of the current streak
* (oldest first); the final fold may be today, still in progress (the
* growing tip). `rounds`/`posts` annotate what was offered that day.
*/
export type VineDay = {
date: string; // YYYY-MM-DD (UTC)
rounds: number;
posts: number;
completed: boolean;
in_progress: boolean;
};
export type SadhnaDashboard = {
// Today's two disciplines.
target_rounds: number;
rounds_completed: number;
chant_done: boolean;
quote_done: boolean;
today_completed: boolean;
// Streak counters.
current_streak: number;
longest_streak: number;
// Plant state (growth 0–100).
growth: number;
stage: SadhnaStage;
alive: boolean;
// Trailing 7 days, oldest first.
week: SadhnaDay[];
// The creeper: one fold per day of the current streak (oldest first),
// plus an in-progress fold for an unfinished today. Capped server-side;
// current_streak holds the true length.
vine_days: VineDay[];
// Rewards. `next_reward_date` is the projected UTC date (YYYY-MM-DD) of the
// next gift if the streak continues. `pending_reward` is a freshly earned
// gift to celebrate now (null when there's nothing to show).
next_reward_date: string | null;
pending_reward: SadhnaRewardGrant | null;
};
/** A gift item in the streak-reward catalog. */
export type SadhnaReward = {
id: string;
key: string;
name: string;
meaning: string;
image_url: string;
active: boolean;
sort: number;
};
/** A reward earned at a streak milestone (a multiple of 7 days). */
export type SadhnaRewardGrant = {
milestone: number;
granted_at: string;
reward: SadhnaReward;
};
// ─── API ───────────────────────────────────────────────────────────────
export const sadhna = {
/** Today's progress + streak + plant state. */
dashboard: () => call<SadhnaDashboard>("/sadhna/dashboard"),
/** Per-day state for an inclusive UTC date range (YYYY-MM-DD), for the
* calendar's week/month views. Range is bounded to a year server-side. */
history: (from: string, to: string) =>
call<SadhnaDay[]>(
`/sadhna/history?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
),
/** Save today's chanting round count; returns the recomputed dashboard. */
saveChant: (roundsCompleted: number) =>
call<SadhnaDashboard>("/sadhna/chant", {
method: "POST",
body: JSON.stringify({ rounds_completed: roundsCompleted }),
}),
/** The user's collected gifts (newest first) + giftable catalog size. */
rewards: () =>
call<{ collected: SadhnaRewardGrant[]; total: number }>("/sadhna/rewards"),
/** Acknowledge pending celebrations after showing the confetti. */
ackRewards: () => call<void>("/sadhna/rewards/ack", { method: "POST" }),
/** Whether daily sādhana reminders are enabled for the caller. */
reminderPrefs: () => call<{ enabled: boolean }>("/sadhna/reminders"),
/** Toggle daily sādhana reminders. */
setReminderPrefs: (enabled: boolean) =>
call<{ enabled: boolean }>("/sadhna/reminders", {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
/** Report the browser's IANA timezone so reminders fire at local time. */
setTimezone: (timezone: string) =>
call<void>("/sadhna/timezone", {
method: "POST",
body: JSON.stringify({ timezone }),
}),
};
+70
View File
@@ -77,6 +77,18 @@ export type Post = {
original_author_name?: string | null;
original_author_handle?: string | null;
original_author_avatar_url?: string | null;
/** Effective like total = denormalized count + pending (un-flushed)
* delta. Always present on hydrated reads (defaults to 0). */
likes_count?: number;
/** Whether the calling viewer has liked this post. Always false / absent
* for anonymous viewers. */
viewer_has_liked?: boolean;
};
/** Result of a like / unlike toggle: the new viewer state + effective total. */
export type LikeResult = {
liked: boolean;
likes_count: number;
};
export type Bookmark = {
@@ -198,6 +210,22 @@ export const posts = {
unreshare(id: string) {
return call<void>(`/posts/${id}/reshare`, { method: "DELETE" });
},
/** Like a post (idempotent). Returns the new viewer state + effective
* total (DB count + pending Redis delta). Auth-required. */
like(id: string) {
return call<LikeResult>(`/posts/${id}/like`, { method: "POST" });
},
/** Unlike a post (idempotent). Same shape as `like`. */
unlike(id: string) {
return call<LikeResult>(`/posts/${id}/like`, { method: "DELETE" });
},
/** One page of users who liked the post (newest first), optional
* name/handle search. `total` is the full matching count. Public. */
likers(id: string, opts: FollowListParams = {}) {
return call<FollowList>(
`/posts/${id}/likes?${followListQuery(opts)}`,
);
},
/** Report a post. category from PostReportCategory; reason optional.
* Auth-required (401 for anonymous callers). Returns nothing (204). */
report(id: string, category: PostReportCategory, reason: string) {
@@ -417,6 +445,10 @@ export type AppNotification = {
actor_name?: string | null;
actor_handle?: string | null;
actor_avatar_url?: string | null;
/** For aggregated `post_like` rows: the liked post + how many likers the
* row represents (renders "X and N-1 others liked your post"). */
post_id?: string | null;
actor_count?: number;
};
export const notifications = {
@@ -493,4 +525,42 @@ export const relationships = {
body: JSON.stringify({ category, reason }),
});
},
/** One page of the profile's accepted followers, optionally filtered by a
* name/handle search. `total` is the full matching count for pagination. */
followers(handle: string, opts: FollowListParams = {}) {
return call<FollowList>(
`/u/${encodeURIComponent(handle)}/followers?${followListQuery(opts)}`,
);
},
/** One page of the users this profile follows (accepted), same shape. */
following(handle: string, opts: FollowListParams = {}) {
return call<FollowList>(
`/u/${encodeURIComponent(handle)}/following?${followListQuery(opts)}`,
);
},
};
/** Lightweight user row in a followers / following list. */
export type UserSummary = {
handle: string;
name: string;
avatar_url: string | null;
};
/** A paginated followers/following page: the rows + total matching count. */
export type FollowList = {
users: UserSummary[];
total: number;
};
export type FollowListParams = { q?: string; limit?: number; offset?: number };
function followListQuery({ q, limit = 30, offset = 0 }: FollowListParams) {
const params = new URLSearchParams({
limit: String(limit),
offset: String(offset),
});
if (q && q.trim()) params.set("q", q.trim());
return params.toString();
}
+17
View File
@@ -0,0 +1,17 @@
import { useSyncExternalStore } from "react";
const emptySubscribe = () => () => {};
/**
* useMounted — false during SSR and the first client render, true thereafter.
* Uses useSyncExternalStore (not an effect), so it's safe for gating
* client-only work like createPortal without tripping the
* set-state-in-effect rule. Mirrors the pattern in components/logo.tsx.
*/
export function useMounted(): boolean {
return useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
}
+19 -2
View File
@@ -3,8 +3,25 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
turbopack: {
root: "/"
}
root: "/",
},
images: {
// R2 public bucket — lets next/image optimize/resize the (large) reward
// images instead of serving the multi-MB originals.
remotePatterns: [{ protocol: "https", hostname: "s3.prabhupadasays.com" }],
},
async rewrites() {
return [
{
source: "/api/script.js",
destination: `${process.env.NEXT_PUBLIC_RYBBIT_HOST}/api/script.js`,
},
{
source: "/api/track",
destination: `${process.env.NEXT_PUBLIC_RYBBIT_HOST}/api/track`,
},
];
},
};
export default nextConfig;
+2
View File
@@ -10,12 +10,14 @@
},
"dependencies": {
"@base-ui/react": "^1.4.0",
"@tanstack/react-virtual": "^3.14.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.8.0",
"next": "16.2.2",
"next-themes": "^0.4.6",
"pusher-js": "^8.5.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-icons": "^5.6.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+222
View File
@@ -0,0 +1,222 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--noto" preserveAspectRatio="xMidYMid meet">
<ellipse transform="rotate(-1.507 101.145 63.828)" cx="101.21" cy="63.87" rx="6.14" ry="6.39" fill="#874d36">
</ellipse>
<ellipse transform="rotate(-1.507 100.561 63.136)" cx="100.63" cy="63.18" rx="5.24" ry="5.45" fill="#d1593f">
</ellipse>
<path d="M101.06 60.5c.42 1.56-1.14 2.85-2.44 2.71c-1.18-.13-2.28-1.41-1.45-2.96c1.09-2.01 3.49-1.23 3.89.25z" fill="#ff7759">
</path>
<ellipse transform="rotate(-2.922 110.772 56.615)" cx="110.83" cy="56.63" rx="6.08" ry="6.33" fill="#874d36">
</ellipse>
<ellipse transform="rotate(-2.922 110.074 56.01)" cx="110.13" cy="56.03" rx="5.19" ry="5.41" fill="#d1593f">
</ellipse>
<path d="M110.11 53.12c.78 1.33-.47 2.97-1.8 3.2c-1.2.2-2.6-.69-2.13-2.35c.61-2.18 3.18-2.12 3.93-.85z" fill="#ff7759">
</path>
<ellipse cx="118.01" cy="34.86" rx="5.99" ry="6.24" fill="#874d36">
</ellipse>
<ellipse cx="117.19" cy="34.5" rx="5.11" ry="5.32" fill="#d1593f">
</ellipse>
<path d="M117.29 32.43c.46 1.55-1.08 2.88-2.38 2.77c-1.18-.1-2.31-1.36-1.51-2.92c1.03-2.05 3.45-1.32 3.89.15z" fill="#ff7759">
</path>
<ellipse cx="112.86" cy="24.02" rx="5.95" ry="6.2" fill="#874d36">
</ellipse>
<ellipse cx="112.3" cy="23.48" rx="5.08" ry="5.29" fill="#d1593f">
</ellipse>
<path d="M112.33 21.78c.56 1.6-.86 3.05-2.15 2.99c-1.17-.06-2.37-1.32-1.69-2.99c.88-2.17 3.31-1.51 3.84 0z" fill="#ff7759">
</path>
<ellipse cx="103.47" cy="16.53" rx="5.92" ry="6.17" fill="#874d36">
</ellipse>
<ellipse cx="102.95" cy="16.03" rx="5.05" ry="5.26" fill="#d1593f">
</ellipse>
<path d="M102.72 13.94c.72 1.5-.57 3.09-1.88 3.19c-1.19.08-2.53-1.01-2.01-2.73c.68-2.23 3.21-1.88 3.89-.46z" fill="#ff7759">
</path>
<ellipse cx="92.61" cy="12.34" rx="5.89" ry="6.14" fill="#874d36">
</ellipse>
<ellipse cx="92.12" cy="11.78" rx="5.03" ry="5.24" fill="#d1593f">
</ellipse>
<path d="M92.08 9.96c.28 1.59-.82 2.77-2.38 2.77c-1.27 0-2.29-1.76-1.33-3.23c1.16-1.79 3.41-1.23 3.71.46z" fill="#ff7759">
</path>
<ellipse cx="80.99" cy="10.78" rx="5.87" ry="6.12" fill="#874d36">
</ellipse>
<ellipse cx="80.17" cy="10.17" rx="5.01" ry="5.22" fill="#d1593f">
</ellipse>
<path d="M80.3 8.44c.47 1.61-1.05 2.98-2.35 2.85c-1.18-.12-2.31-1.43-1.53-3.04c1.02-2.1 3.44-1.33 3.88.19z" fill="#ff7759">
</path>
<ellipse cx="69.29" cy="10.75" rx="5.86" ry="6.1" fill="#874d36">
</ellipse>
<ellipse cx="68.85" cy="10.05" rx="5" ry="5.21" fill="#d1593f">
</ellipse>
<path d="M68.66 8.08c.68 1.46-.64 3.01-1.94 3.09c-1.19.07-2.49-1-1.93-2.66c.72-2.17 3.23-1.82 3.87-.43z" fill="#ff7759">
</path>
<ellipse cx="58" cy="12.21" rx="5.85" ry="6.09" fill="#874d36">
</ellipse>
<ellipse cx="57.26" cy="11.65" rx="4.99" ry="5.2" fill="#d1593f">
</ellipse>
<path d="M57.48 9.69c.46 1.55-1.08 2.88-2.38 2.77c-1.18-.1-2.31-1.36-1.51-2.92c1.04-2.05 3.46-1.32 3.89.15z" fill="#ff7759">
</path>
<g>
<ellipse cx="46.91" cy="14.49" rx="5.85" ry="6.09" fill="#874d36">
</ellipse>
<ellipse cx="46.48" cy="13.65" rx="4.99" ry="5.2" fill="#d1593f">
</ellipse>
<path d="M42.54 13.31c-.36-1.75 1.38-2.82 2.6-2.72c1.12.09 2.16 1.55 1.17 3.08c-1.36 2.09-3.46 1.17-3.77-.36z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="36.38" cy="19.27" rx="5.85" ry="6.09" fill="#874d36">
</ellipse>
<ellipse cx="35.83" cy="18.48" rx="4.99" ry="5.2" fill="#d1593f">
</ellipse>
<path d="M36.37 16.83c.08 1.67-1.58 2.73-2.87 2.37c-1.12-.31-2.1-1.79-1.03-3.21c1.33-1.77 3.83-.74 3.9.84z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="26.97" cy="26.42" rx="5.86" ry="6.1" fill="#874d36">
</ellipse>
<ellipse cx="26.52" cy="25.79" rx="5" ry="5.21" fill="#d1593f">
</ellipse>
<path d="M28.04 23.12c.26 1.66-1.74 2.54-3.08 2.37c-1.24-.15-2.27-1.58-1.05-2.95c1.65-1.86 3.9-.87 4.13.58z" fill="#ff7759">
</path>
</g>
<g>
<ellipse transform="rotate(-16.544 19.35 35.557)" cx="19.35" cy="35.56" rx="5.87" ry="6.12" fill="#874d36">
</ellipse>
<ellipse transform="rotate(-16.544 18.876 34.764)" cx="18.87" cy="34.76" rx="5.01" ry="5.22" fill="#d1593f">
</ellipse>
<path d="M20.16 32.37c.08.89-.45 1.77-1.17 2.26c-.7.48-1.62.58-2.42.33a2.047 2.047 0 0 1-.78-3.1c1.07-1.63 4.15-1.93 4.37.51z" fill="#ff7759">
</path>
</g>
<g>
<ellipse transform="rotate(-16.096 13.825 46.146)" cx="13.82" cy="46.15" rx="5.89" ry="6.14" fill="#874d36">
</ellipse>
<ellipse transform="rotate(-16.096 13.377 45.34)" cx="13.37" cy="45.34" rx="5.03" ry="5.24" fill="#d1593f">
</ellipse>
<path d="M15.17 43.56c-.2.87-.98 1.54-1.82 1.78c-.81.23-1.72.05-2.4-.44c-.5-.36-1.31-1.98.22-3.19s4.55-.54 4 1.85z" fill="#ff7759">
</path>
</g>
<g>
<ellipse transform="rotate(-9.97 10.57 57.697)" cx="10.56" cy="57.7" rx="5.92" ry="6.17" fill="#874d36">
</ellipse>
<ellipse transform="rotate(-9.97 10.188 56.916)" cx="10.18" cy="56.92" rx="5.05" ry="5.26" fill="#d1593f">
</ellipse>
<path d="M12.47 55.13c-.37.81-1.27 1.31-2.14 1.38c-.84.06-1.69-.3-2.26-.92c-.41-.45-.88-2.21.86-3.08s4.56.39 3.54 2.62z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="9.95" cy="70.1" rx="5.95" ry="6.2" fill="#874d36">
</ellipse>
<ellipse cx="9.88" cy="69.1" rx="5.08" ry="5.29" fill="#d1593f">
</ellipse>
<path d="M12.2 67.4c-.27.86-1.09 1.46-1.95 1.63c-.83.16-1.72-.1-2.37-.64c-.49-.41-1.13-2.16.53-3.32c1.56-1.11 4.54-.09 3.79 2.33z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="12.39" cy="81.97" rx="5.99" ry="6.24" fill="#874d36">
</ellipse>
<ellipse cx="12.53" cy="81.01" rx="5.11" ry="5.32" fill="#d1593f">
</ellipse>
<path d="M14.44 78.62c-.29 1.41-1.4 1.71-2.26 1.91c-2.1.49-3.97-1.31-2.1-3.13c1.13-1.11 4.89-1.36 4.36 1.22z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="18.32" cy="91.72" rx="6.03" ry="6.28" fill="#874d36">
</ellipse>
<ellipse cx="18.39" cy="90.8" rx="5.15" ry="5.36" fill="#d1593f">
</ellipse>
<path d="M20.8 88.6c-.08.9-.83 1.72-1.69 1.9c-1.46.31-4.2-.32-2.53-3.15c.96-1.64 4.48-1.67 4.22 1.25z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="28.28" cy="98.84" rx="6.08" ry="6.33" fill="#874d36">
</ellipse>
<ellipse cx="28.11" cy="97.87" rx="5.19" ry="5.41" fill="#d1593f">
</ellipse>
<path d="M29.93 95.63c-.13.89-.86 1.58-1.68 1.91c-1.21.49-4.26.03-2.74-3.1c.81-1.67 4.82-1.58 4.42 1.19z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="39.61" cy="101.12" rx="6.14" ry="6.39" fill="#874d36">
</ellipse>
<ellipse cx="39.5" cy="100.13" rx="5.24" ry="5.45" fill="#d1593f">
</ellipse>
<path d="M41.47 97.55c-.12.89-.58 1.34-1.66 1.92c-1.64.89-4.33-.63-2.93-3.08c.96-1.67 4.97-1.69 4.59 1.16z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="51.06" cy="98.89" rx="6.19" ry="6.45" fill="#874d36">
</ellipse>
<ellipse cx="50.76" cy="97.92" rx="5.29" ry="5.51" fill="#d1593f">
</ellipse>
<path d="M53.13 95.85c-.11.9-.82 1.65-1.65 1.93c-.82.28-1.75.13-2.5-.31c-.64-.37-1.17-1.03-1.18-1.79c0-.41.28-.89.53-.9c1.05-1.95 5.17-1.89 4.8 1.07z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="63.02" cy="95.73" rx="6.26" ry="6.52" fill="#874d36">
</ellipse>
<ellipse cx="62.6" cy="94.96" rx="5.34" ry="5.56" fill="#d1593f">
</ellipse>
<path d="M64.26 92.59c-.01.91-.62 1.74-1.41 2.11c-.79.37-1.73.32-2.54-.02c-.7-.3-1.42-1.47-1.18-2.38c.67-2.52 5.15-2.78 5.13.29z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="74.83" cy="95.53" rx="6.33" ry="6.59" fill="#874d36">
</ellipse>
<ellipse cx="74.45" cy="94.58" rx="5.4" ry="5.63" fill="#d1593f">
</ellipse>
<path d="M76.5 92.39c-.15.9-.79 1.67-1.62 1.96s-1.83.41-2.81-.28c-.64-.45-1.28-1.58-.75-2.61c1.1-2.18 5.66-1.97 5.18.93z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="87.06" cy="82.42" rx="6.26" ry="6.52" fill="#874d36">
</ellipse>
<ellipse transform="rotate(-71.119 86.409 81.782)" cx="86.41" cy="81.78" rx="5.56" ry="5.34" fill="#d1593f">
</ellipse>
<path d="M86.71 80.38c-.36 1.77-2.31 2.37-3.36 1.65c-.95-.66-1.29-2.45.14-3.64c1.87-1.55 3.56.32 3.22 1.99z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="117.83" cy="46.79" rx="6.03" ry="6.28" fill="#874d36">
</ellipse>
<ellipse cx="117.19" cy="46.5" rx="5.15" ry="5.36" fill="#d1593f">
</ellipse>
<path d="M116.94 44.2c.46 1.55-.91 3.27-2.21 3.16c-1.18-.1-2.31-1.36-1.51-2.92c1.04-2.04 3.28-1.71 3.72-.24z" fill="#ff7759">
</path>
</g>
<g>
<ellipse cx="86.64" cy="94.67" rx="6.33" ry="6.59" fill="#874d36">
</ellipse>
<ellipse cx="86.06" cy="93.7" rx="5.4" ry="5.63" fill="#d1593f">
</ellipse>
<path d="M86.97 90.73c.3.84.01 1.86-.61 2.51c-.62.65-1.53.97-2.4.97c-.78 0-1.85-.62-1.79-2.15c.08-2.46 3.75-4.27 4.8-1.33z" fill="#ff7759">
</path>
<path d="M82.48 97.94c.47.51 1.75.88 2.39.63c1.27-.5 1.23-1.67 3.18-2.84c1.25-.75 1.78-.58 2.82-1.06c.75-.35.57-1.29.51-1.9c0 0 2.35 3.66-2.22 6.4c-4.53 2.71-6.68-1.23-6.68-1.23z" fill="#874d36">
</path>
</g>
<g>
<ellipse cx="92.6" cy="71.76" rx="6.19" ry="6.45" fill="#874d36">
</ellipse>
<ellipse cx="91.75" cy="71.3" rx="5.29" ry="5.51" fill="#d1593f">
</ellipse>
<path d="M92.16 68.53c.26 1.59-1.07 2.76-2.38 2.77c-1.29.01-2.51-1.48-1.51-2.92c1.39-2.02 3.65-1.37 3.89.15z" fill="#ff7759">
</path>
</g>
<path d="M91.19 95.91c-2.41-.26-4.56 1.98-4.79 4.29c-.47 4.73 4.78 6.41 4.78 6.41c.45.21 1.61.75 2.92 2.83c4.54 7.24 11.83 12.96 20.33 13.88c2.76.3 9.35-14.38 4.15-16.31c-1.56-.58-3.29-.32-4.93-.11c-3.02.39-6.09.6-9.1.13c-2.23-.35-7.07-2.6-8.16-4.9c0-.01-.27-5.69-5.2-6.22z" fill="#ffca28">
</path>
<path d="M94.44 103.33c3.66 2.46 8.37 3.27 12.64 2.19c4.04-1.03 7.93-3.68 12.01-2.83" fill="none" stroke="#ffca28" stroke-width="2.523" stroke-linecap="round" stroke-miterlimit="10">
</path>
<path d="M92.96 106.96c.21 4.56.86 9.29 4.5 12.05c3.35 2.55 7.88 3.34 11.98 1.33" fill="none" stroke="#ffca28" stroke-width="2.523" stroke-linecap="round" stroke-miterlimit="10">
</path>
<path d="M116.62 107.37c-1.55.87-1.07 5.34-1.15 6.28c-.28 3.33-2.65 2.4-3.64 5.41c-.52 1.57-.29 4.04 1.58 4.24c6.42.69 7.55-6.32 7.71-7.92c.67-6.44-2.09-9.36-4.5-8.01z" fill="#e2a610">
</path>
<g>
<path d="M115.59 114.36c-6.91 0-13.86-2.26-18.62-7.04c-1.94-1.95-.79-2.57-.28-2.11c5.55 4.98 13.68 7.69 21.31 6.98l.19 2.05c-.85.09-1.72.12-2.6.12z" fill="#e2a610">
</path>
</g>
<g>
<path d="M115.71 120.34c-5.01-.82-10.92-3.41-13.84-5.81c-1.83-1.5-2.16-2.45-2.16-2.45c4.21 3.42 10.82 5.66 16.17 6.53l-.17 1.73z" fill="#e2a610">
</path>
</g>
<path d="M91.83 106.96s.47-1.22 2.11-2.55c1.95-1.59 2.87-1.63 2.87-1.63c-.29-.45-.4-2.12-1.12-3.46c0 0 .2 2.08-2.58 4.07c-1.82 1.3-5.06 1.28-5.06 1.28c1.37 1.42 3.78 2.29 3.78 2.29z" fill="#e2a610">
</path>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+79
View File
@@ -0,0 +1,79 @@
/* Prabhupāda Says — Web Push service worker.
*
* Handles incoming push messages (shows a notification) and clicks (focuses
* an existing same-origin tab and navigates it, or opens a new one). The push
* payload is the JSON the BFF sends:
* { title, body, url, icon, tag, require_interaction }
*/
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) =>
event.waitUntil(self.clients.claim()),
);
self.addEventListener("push", (event) => {
event.waitUntil(
(async () => {
let data = {};
if (event.data) {
try {
// event.data.json() is synchronous; text() is a Promise — only
// awaited in the fallback.
data = event.data.json();
} catch {
data = { body: await event.data.text() };
}
}
const title = data.title || "Prabhupāda Says";
const options = {
body: data.body || "",
icon: data.icon || "/icon-192.png",
badge: "/icon-192.png",
// A tag dedupes a series (e.g. daily reminders replace the previous
// one); renotify re-alerts when a tagged notification is replaced.
tag: data.tag || undefined,
renotify: !!data.tag,
// Reminders set this so they stay until acted on (desktop otherwise
// auto-dismisses). Defaults off for everything else.
requireInteraction: data.require_interaction === true,
vibrate: [100, 50, 100],
data: { url: data.url || "/" },
actions: [{ action: "open", title: "Open" }],
};
await self.registration.showNotification(title, options);
})(),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
// The only action is "open"; the default body click behaves the same.
const url = (event.notification.data && event.notification.data.url) || "/";
const target = new URL(url, self.location.origin).href;
event.waitUntil(
(async () => {
const list = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
// Reuse a single existing same-origin tab — focus it and navigate only
// that one (not every matched window).
for (const client of list) {
if (client.url.startsWith(self.location.origin)) {
await client.focus();
if ("navigate" in client) {
try {
await client.navigate(target);
} catch {
/* cross-document navigation can reject; focus already happened */
}
}
return;
}
}
if (self.clients.openWindow) await self.clients.openWindow(target);
})(),
);
});
+24
View File
@@ -1211,6 +1211,18 @@
postcss "^8.5.6"
tailwindcss "4.2.2"
"@tanstack/react-virtual@^3.14.2":
version "3.14.2"
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz#ff1f97cd52f2bf4bda7f6d141bc9af80010daf45"
integrity sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==
dependencies:
"@tanstack/virtual-core" "3.17.0"
"@tanstack/virtual-core@3.17.0":
version "3.17.0"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz#2949d4aaeaf73f89bab10bf86e22905f5a668acc"
integrity sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==
"@ts-morph/common@~0.27.0":
version "0.27.0"
resolved "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz"
@@ -4188,6 +4200,13 @@ punycode@^2.1.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
pusher-js@^8.5.0:
version "8.5.0"
resolved "https://registry.yarnpkg.com/pusher-js/-/pusher-js-8.5.0.tgz#329d4fb4b5e4ae0a5e0a558b6da1a852c4badae4"
integrity sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==
dependencies:
tweetnacl "^1.0.3"
qs@^6.14.0, qs@^6.14.1:
version "6.15.1"
resolved "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz"
@@ -4938,6 +4957,11 @@ tw-animate-css@^1.4.0:
resolved "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz"
integrity sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==
tweetnacl@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596"
integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"