Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0334e03c3c | ||
|
|
ecb32d2e85 | ||
|
|
d5fb350652 | ||
|
|
bcffe9684f | ||
|
|
0db339254d |
@@ -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
@@ -31,7 +31,7 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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("");
|
||||
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { Compass, LogIn, Sparkles } from "lucide-react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { SadhnaStreakCTA } from "@/components/sadhna/streak-cta";
|
||||
|
||||
/**
|
||||
* / — the Home (Feed) gate.
|
||||
@@ -79,6 +80,10 @@ export default function HomePage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Daily sādhana streak — a simple entry point until the feed lands. */}
|
||||
<div className="mt-6 w-full max-w-md">
|
||||
<SadhnaStreakCTA />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
<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's progress.
|
||||
</span>
|
||||
) : saveStatus === "saving" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" /> Saving…
|
||||
</>
|
||||
) : saveStatus === "error" ? (
|
||||
<span className="text-destructive">
|
||||
Couldn't save — keep chanting, we'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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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.";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ export default function MyBookmarksPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
bookmarks
|
||||
.list()
|
||||
.then((r) => setList(r ?? []))
|
||||
|
||||
@@ -35,7 +35,6 @@ export default function CollectionDetailPage(props: {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
collections
|
||||
.get(id)
|
||||
.then(({ collection, posts }) => {
|
||||
|
||||
@@ -51,7 +51,6 @@ function MyProfilePageInner() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
posts
|
||||
.mine()
|
||||
.then((rows) => setFeed(rows ?? []))
|
||||
|
||||
@@ -26,7 +26,6 @@ export default function MyPostsFeedPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setLoading(true);
|
||||
posts
|
||||
.mine()
|
||||
.then((rows) => setFeed(rows ?? []))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+24
-12
@@ -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";
|
||||
@@ -73,22 +75,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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,181 @@
|
||||
"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`;
|
||||
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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PushToggle — the settings control for browser push notifications. Reads the
|
||||
* current state on mount (supported? permission? server-configured?
|
||||
* subscribed?) and lets the user enable/disable. Renders as a settings row
|
||||
* matching the rest of /settings.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
disablePush,
|
||||
enablePush,
|
||||
isPushEnabled,
|
||||
pushConfigured,
|
||||
pushPermission,
|
||||
pushSupported,
|
||||
} from "@/lib/push";
|
||||
|
||||
type State =
|
||||
| "loading"
|
||||
| "unsupported"
|
||||
| "unavailable" // server has no VAPID keys
|
||||
| "denied" // browser permission blocked
|
||||
| "on"
|
||||
| "off";
|
||||
|
||||
export function PushToggle() {
|
||||
const [state, setState] = useState<State>("loading");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!pushSupported()) return finish("unsupported");
|
||||
if (pushPermission() === "denied") return finish("denied");
|
||||
if (!(await pushConfigured())) return finish("unavailable");
|
||||
finish((await isPushEnabled()) ? "on" : "off");
|
||||
function finish(s: State) {
|
||||
if (!cancelled) setState(s);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (state === "on") {
|
||||
await disablePush();
|
||||
setState("off");
|
||||
toast.success("Push notifications turned off");
|
||||
} else {
|
||||
await enablePush();
|
||||
setState("on");
|
||||
toast.success("Push notifications turned on");
|
||||
}
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : "Something went wrong.";
|
||||
// A fresh block surfaces as a permission error — reflect it.
|
||||
if (pushPermission() === "denied") setState("denied");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const description = describe(state);
|
||||
|
||||
return (
|
||||
<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" ? "…" : "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 "unsupported":
|
||||
return "This browser doesn't support push notifications.";
|
||||
case "unavailable":
|
||||
return "Push notifications aren't available right now.";
|
||||
default:
|
||||
return "Checking…";
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<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'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,
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<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",
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* StreakCalendar — the sādhana streak calendar with week/month views.
|
||||
*
|
||||
* Shows the current/best streak and a grid of days; a lotus blooms beneath
|
||||
* every day the sādhana was completed (both disciplines). The user can
|
||||
* switch between a single-week view and a full-month grid, and page
|
||||
* backward/forward through time. "Next" stops at the current period — there
|
||||
* is nothing to show in the future.
|
||||
*
|
||||
* Streak counters come from the dashboard payload; the grid itself is fed by
|
||||
* the /sadhna/history range endpoint, fetched per visible period and cached
|
||||
* so paging back to a period you've seen is instant. Everything is bucketed
|
||||
* by UTC calendar day to match the backend.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronLeft, ChevronRight, Gift } from "lucide-react";
|
||||
import { sadhna, type SadhnaDashboard, type SadhnaDay } from "@/lib/sadhna";
|
||||
import { StreakFlame } from "@/components/sadhna/streak-flame";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DAY = 86400000;
|
||||
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
|
||||
type View = "week" | "month";
|
||||
|
||||
// ── UTC date helpers (operate on epoch-ms at UTC midnight) ──
|
||||
function ymd(ms: number): string {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
function startOfDayUTC(d: Date): number {
|
||||
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||||
}
|
||||
function startOfWeek(ms: number): number {
|
||||
return ms - new Date(ms).getUTCDay() * DAY;
|
||||
}
|
||||
|
||||
export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) {
|
||||
const todayMs = startOfDayUTC(new Date());
|
||||
const todayStr = ymd(todayMs);
|
||||
|
||||
const [view, setView] = useState<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()}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* 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 { toast } from "sonner";
|
||||
@@ -90,37 +90,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).
|
||||
@@ -202,7 +200,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 +222,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 +245,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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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's background, or remove
|
||||
the current one.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
getBookMeta,
|
||||
getIntroSegments,
|
||||
getIntroSegment,
|
||||
getChapter,
|
||||
getContent,
|
||||
getSidebarItems,
|
||||
type BookMeta,
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
|
||||
/** 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
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,48 @@
|
||||
/* Prabhupāda Says — Web Push service worker.
|
||||
*
|
||||
* Handles incoming push messages (shows a notification) and clicks (focuses
|
||||
* an existing tab or opens the target URL). The push payload is the JSON the
|
||||
* BFF sends: { title, body, url, icon, tag }.
|
||||
*/
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", (event) =>
|
||||
event.waitUntil(self.clients.claim()),
|
||||
);
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
data = { body: event.data && event.data.text ? event.data.text() : "" };
|
||||
}
|
||||
const title = data.title || "Prabhupāda Says";
|
||||
const options = {
|
||||
body: data.body || "",
|
||||
icon: data.icon || "/logo_dark.png",
|
||||
badge: "/logo_dark.png",
|
||||
tag: data.tag || undefined,
|
||||
data: { url: data.url || "/" },
|
||||
};
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url =
|
||||
(event.notification.data && event.notification.data.url) || "/";
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((list) => {
|
||||
for (const client of list) {
|
||||
if ("focus" in client) {
|
||||
if ("navigate" in client) client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -4188,6 +4188,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 +4945,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"
|
||||
|
||||
Reference in New Issue
Block a user