Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647a803b96 | ||
|
|
e36afdd1a5 | ||
|
|
ef6e280524 | ||
|
|
cf733062f8 | ||
|
|
421b41cade | ||
|
|
deeda40c9d | ||
|
|
90df5bc784 |
@@ -99,7 +99,7 @@ export default function NotificationsPage() {
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
|
||||
{items.map((n) => (
|
||||
<NotificationRow key={n.id} n={n} />
|
||||
<NotificationRow key={n.id} n={n} selfHandle={user.handle} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -107,9 +107,15 @@ export default function NotificationsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationRow({ n }: { n: AppNotification }) {
|
||||
function NotificationRow({
|
||||
n,
|
||||
selfHandle,
|
||||
}: {
|
||||
n: AppNotification;
|
||||
selfHandle: string;
|
||||
}) {
|
||||
const display = n.actor_name || (n.actor_handle ? `@${n.actor_handle}` : "Someone");
|
||||
const message = messageFor(n.kind);
|
||||
const message = messageFor(n);
|
||||
const unread = !n.read_at;
|
||||
|
||||
const body = (
|
||||
@@ -131,10 +137,18 @@ function NotificationRow({ n }: { n: AppNotification }) {
|
||||
</div>
|
||||
);
|
||||
|
||||
// Link to the actor's profile when we know their handle.
|
||||
return n.actor_handle ? (
|
||||
// A like links to your own post; everything else links to the actor's
|
||||
// profile (when we know their handle).
|
||||
const href =
|
||||
n.kind === "post_like" && n.post_id
|
||||
? `/u/${selfHandle}/posts#post-${n.post_id}`
|
||||
: n.actor_handle
|
||||
? `/u/${n.actor_handle}`
|
||||
: null;
|
||||
|
||||
return href ? (
|
||||
<li>
|
||||
<Link href={`/u/${n.actor_handle}`} className="block">
|
||||
<Link href={href} className="block">
|
||||
{body}
|
||||
</Link>
|
||||
</li>
|
||||
@@ -152,12 +166,17 @@ function cnRow(unread: boolean) {
|
||||
|
||||
/** Human-readable tail for each notification kind. Add cases as new
|
||||
* notification kinds are introduced on the backend. */
|
||||
function messageFor(kind: string): string {
|
||||
switch (kind) {
|
||||
function messageFor(n: AppNotification): string {
|
||||
switch (n.kind) {
|
||||
case "follow_request":
|
||||
return "requested to follow you.";
|
||||
case "follow_accepted":
|
||||
return "accepted your follow request.";
|
||||
case "post_like": {
|
||||
const others = (n.actor_count ?? 1) - 1;
|
||||
if (others <= 0) return "liked your post.";
|
||||
return `and ${others} ${others === 1 ? "other" : "others"} liked your post.`;
|
||||
}
|
||||
default:
|
||||
return "sent you a notification.";
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export default function HomePage() {
|
||||
const authed = !loading && !!user;
|
||||
|
||||
return (
|
||||
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24">
|
||||
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-16 sm:py-24 select-none">
|
||||
<div className="flex flex-col items-center text-center gap-5">
|
||||
<span
|
||||
aria-hidden
|
||||
|
||||
@@ -211,7 +211,7 @@ export default function ChantPage() {
|
||||
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">
|
||||
<main className="flex-1 w-full max-w-lg mx-auto px-4 sm:px-6 py-5 flex flex-col select-none">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/sadhna-streak"
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function SadhnaStreakPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-5">
|
||||
<main className="flex-1 w-full max-w-2xl mx-auto px-4 sm:px-6 py-5 flex flex-col gap-5 select-none">
|
||||
<header className="text-center space-y-0.5">
|
||||
<h1 className="font-(family-name:--font-title) text-2xl sm:text-3xl font-semibold text-foreground">
|
||||
Daily Sādhana
|
||||
|
||||
@@ -46,6 +46,20 @@ export const metadata: Metadata = {
|
||||
title: "Prabhupāda Says",
|
||||
description:
|
||||
"Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
// Makes the site a standalone Home Screen app on iOS — the prerequisite
|
||||
// for Web Push on iPhone/iPad (iOS 16.4+). Emits
|
||||
// <meta name="apple-mobile-web-app-capable" content="yes"> etc.
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: "Prabhupāda Says",
|
||||
statusBarStyle: "default",
|
||||
},
|
||||
// Home Screen / tab icons. The apple-touch-icon is what iOS uses for the
|
||||
// installed PWA (the prerequisite for Web Push on iPhone/iPad).
|
||||
icons: {
|
||||
icon: "/icon-192.png",
|
||||
apple: "/apple-icon.png",
|
||||
},
|
||||
...ogDefault(),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/**
|
||||
* Web App Manifest — makes the site installable as a standalone PWA. This is
|
||||
* what lets iOS (16.4+) deliver Web Push: notifications only work once the
|
||||
* site is added to the Home Screen and launched standalone. Next serves this
|
||||
* at /manifest.webmanifest and links it automatically.
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "Prabhupāda Says",
|
||||
short_name: "Prabhupāda",
|
||||
description:
|
||||
"Browse the books and teachings of His Divine Grace A.C. Bhaktivedanta Swami Prabhupāda",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#fdf7ee",
|
||||
theme_color: "#fdf7ee",
|
||||
icons: [
|
||||
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,8 @@ function messageFor(e: IncomingEvent): string {
|
||||
return `${who} requested to follow you`;
|
||||
case "follow_accepted":
|
||||
return `${who} accepted your follow request`;
|
||||
case "post_like":
|
||||
return `${who} liked your post`;
|
||||
default:
|
||||
return "You have a new notification";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FollowListDrawer — the followers / following list for a profile. A thin
|
||||
* wrapper over the generic virtualized UserListDrawer, wired to the
|
||||
* relationships followers/following endpoints.
|
||||
*/
|
||||
|
||||
import { relationships } from "@/lib/social";
|
||||
import { UserListDrawer } from "@/components/social/user-list-drawer";
|
||||
|
||||
export type FollowMode = "followers" | "following";
|
||||
|
||||
export function FollowListDrawer({
|
||||
open,
|
||||
mode,
|
||||
handle,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: FollowMode;
|
||||
handle: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UserListDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={mode === "followers" ? "Followers" : "Following"}
|
||||
depsKey={`${mode}:${handle}`}
|
||||
fetchPage={(params) =>
|
||||
mode === "followers"
|
||||
? relationships.followers(handle, params)
|
||||
: relationships.following(handle, params)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,10 @@ import { Settings, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RelationshipResult, RelationshipState } from "@/lib/social";
|
||||
import { ProfileActions } from "@/components/profile/profile-actions";
|
||||
import {
|
||||
FollowListDrawer,
|
||||
type FollowMode,
|
||||
} from "@/components/profile/follow-list-drawer";
|
||||
|
||||
export function ProfileHeader({
|
||||
avatarURL,
|
||||
@@ -83,7 +87,12 @@ export function ProfileHeader({
|
||||
// without a refetch. Re-seeds per profile via the call-site key.
|
||||
const [followers, setFollowers] = useState(followerCount ?? 0);
|
||||
const [following, setFollowing] = useState(followingCount ?? 0);
|
||||
const [rel, setRel] = useState<RelationshipState | null>(relationship ?? null);
|
||||
const [rel, setRel] = useState<RelationshipState | null>(
|
||||
relationship ?? null,
|
||||
);
|
||||
|
||||
// Which follow list (if any) is open in the drawer.
|
||||
const [followList, setFollowList] = useState<FollowMode | null>(null);
|
||||
|
||||
const handleResult = (r: RelationshipResult) => {
|
||||
setRel(r.relationship);
|
||||
@@ -103,7 +112,7 @@ export function ProfileHeader({
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight">
|
||||
<h1 className="font-(family-name:--font-title) font-semibold text-xl sm:text-2xl text-foreground leading-tight">
|
||||
{name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">@{handle}</p>
|
||||
@@ -116,8 +125,16 @@ export function ProfileHeader({
|
||||
) : null}
|
||||
{showCounts ? (
|
||||
<>
|
||||
<Stat n={followers} label="followers" />
|
||||
<Stat n={following} label="following" />
|
||||
<Stat
|
||||
n={followers}
|
||||
label="followers"
|
||||
onClick={() => setFollowList("followers")}
|
||||
/>
|
||||
<Stat
|
||||
n={following}
|
||||
label="following"
|
||||
onClick={() => setFollowList("following")}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -164,17 +181,51 @@ export function ProfileHeader({
|
||||
onClose={() => setZoomOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{showCounts ? (
|
||||
<FollowListDrawer
|
||||
open={followList !== null}
|
||||
mode={followList ?? "followers"}
|
||||
handle={handle}
|
||||
onClose={() => setFollowList(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One inline profile stat — bold count over a muted label. */
|
||||
function Stat({ n, label }: { n: number; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center leading-tight">
|
||||
<span className="font-semibold text-lg text-foreground tabular-nums">{n}</span>
|
||||
/** One inline profile stat — bold count over a muted label. Renders as a
|
||||
* button (opening the follow list) when `onClick` is provided, else a plain
|
||||
* div (e.g. the posts count). */
|
||||
function Stat({
|
||||
n,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
n: number;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const inner = (
|
||||
<>
|
||||
<span className="font-semibold text-lg text-foreground tabular-nums">
|
||||
{n}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (!onClick) {
|
||||
return (
|
||||
<div className="flex flex-col items-center leading-tight">{inner}</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center leading-tight rounded-md px-2 -mx-1 py-0.5 transition-colors hover:bg-muted/60 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
disablePush,
|
||||
enablePush,
|
||||
isPushEnabled,
|
||||
needsIOSInstall,
|
||||
pushConfigured,
|
||||
pushPermission,
|
||||
pushSupported,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type State =
|
||||
| "loading"
|
||||
| "unsupported"
|
||||
| "ios-install" // iOS: must add to Home Screen first
|
||||
| "unavailable" // server has no VAPID keys
|
||||
| "denied" // browser permission blocked
|
||||
| "on"
|
||||
@@ -33,7 +35,9 @@ export function PushToggle() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
if (!pushSupported()) return finish("unsupported");
|
||||
if (!pushSupported()) {
|
||||
return finish(needsIOSInstall() ? "ios-install" : "unsupported");
|
||||
}
|
||||
if (pushPermission() === "denied") return finish("denied");
|
||||
if (!(await pushConfigured())) return finish("unavailable");
|
||||
finish((await isPushEnabled()) ? "on" : "off");
|
||||
@@ -101,7 +105,11 @@ export function PushToggle() {
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{state === "loading" ? "…" : "Unavailable"}
|
||||
{state === "loading"
|
||||
? "…"
|
||||
: state === "ios-install"
|
||||
? null
|
||||
: "Unavailable"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -118,6 +126,8 @@ function describe(state: State): string {
|
||||
return "Get notified about follow requests and more, even when the app is closed.";
|
||||
case "denied":
|
||||
return "Blocked in your browser. Enable notifications for this site in your browser settings, then reload.";
|
||||
case "ios-install":
|
||||
return "On iPhone & iPad, notifications work only from the Home Screen app. Tap the Share icon in Safari, choose “Add to Home Screen”, then open Prabhupāda Says from the new icon and turn this on.";
|
||||
case "unsupported":
|
||||
return "This browser doesn't support push notifications.";
|
||||
case "unavailable":
|
||||
|
||||
@@ -55,7 +55,7 @@ export function RewardCelebration({
|
||||
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"
|
||||
className="relative z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl select-none"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-primary">
|
||||
A gift for your sādhana
|
||||
|
||||
@@ -124,7 +124,7 @@ function RewardDetail({
|
||||
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 z-10 w-full max-w-md max-h-[90vh] overflow-y-auto rounded-3xl border border-border bg-card p-6 text-center shadow-2xl select-none">
|
||||
<div className="relative mx-auto mb-4 flex size-40 items-center justify-center">
|
||||
<div className="absolute inset-0 rounded-full bg-primary/10 blur-2xl" />
|
||||
<Image
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
*
|
||||
* 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.
|
||||
* so paging back to a period you've seen is instant. "Today" is the browser's
|
||||
* local date, matching the backend's local-timezone day boundary.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -26,19 +26,24 @@ const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||
|
||||
type View = "week" | "month";
|
||||
|
||||
// ── UTC date helpers (operate on epoch-ms at UTC midnight) ──
|
||||
// Each calendar day is represented as the UTC-midnight epoch-ms of that date
|
||||
// (so the date string and all arithmetic stay tz-free and internally
|
||||
// consistent). "Today", however, is anchored to the browser's LOCAL date so
|
||||
// the day boundary matches the backend's local-timezone day reset.
|
||||
function ymd(ms: number): string {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
function startOfDayUTC(d: Date): number {
|
||||
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||||
// UTC-midnight ms whose calendar date is the browser's local today.
|
||||
function localTodayMs(): number {
|
||||
const d = new Date();
|
||||
return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
function startOfWeek(ms: number): number {
|
||||
return ms - new Date(ms).getUTCDay() * DAY;
|
||||
}
|
||||
|
||||
export function StreakCalendar({ dash }: { dash: SadhnaDashboard | null }) {
|
||||
const todayMs = startOfDayUTC(new Date());
|
||||
const todayMs = localTodayMs();
|
||||
const todayStr = ymd(todayMs);
|
||||
|
||||
const [view, setView] = useState<View>("week");
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
* see the in-place auth modal before the underlying action runs.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
HiHeart,
|
||||
HiOutlineArrowPath,
|
||||
HiOutlineChatBubbleOvalLeft,
|
||||
HiOutlineFolderPlus,
|
||||
@@ -37,6 +38,7 @@ import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
|
||||
import { PostLikesDrawer } from "@/components/social/post-likes-drawer";
|
||||
import { useSharePost } from "./share-post-provider";
|
||||
|
||||
// All icons come from the Heroicons v2 outline family (react-icons
|
||||
@@ -60,6 +62,12 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
const [reshared, setReshared] = useState<boolean>(
|
||||
!!post.viewer_has_reshared,
|
||||
);
|
||||
// Like state — seeded from the hydrated post, toggled optimistically and
|
||||
// reconciled to the server's effective count on success.
|
||||
const [liked, setLiked] = useState<boolean>(!!post.viewer_has_liked);
|
||||
const [likeCount, setLikeCount] = useState<number>(post.likes_count ?? 0);
|
||||
const [likesOpen, setLikesOpen] = useState(false);
|
||||
const likeBusy = useRef(false);
|
||||
|
||||
const isOwner = !!user && user.id === post.user_id;
|
||||
// Owners and resharers both see Send — the post is "on their account"
|
||||
@@ -71,6 +79,32 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
await fn();
|
||||
}
|
||||
|
||||
// Like toggle — optimistic flip + count nudge, reconciled to the server's
|
||||
// effective total (DB count + pending Redis delta) on success, rolled back
|
||||
// on error. A ref guard ignores taps while a request is in flight.
|
||||
async function onLikeToggle() {
|
||||
if (likeBusy.current) return;
|
||||
likeBusy.current = true;
|
||||
const next = !liked;
|
||||
setLiked(next);
|
||||
setLikeCount((c) => Math.max(0, c + (next ? 1 : -1)));
|
||||
try {
|
||||
const res = next
|
||||
? await postsApi.like(post.id)
|
||||
: await postsApi.unlike(post.id);
|
||||
setLiked(res.liked);
|
||||
setLikeCount(res.likes_count);
|
||||
} catch (err) {
|
||||
setLiked(!next);
|
||||
setLikeCount((c) => Math.max(0, c + (next ? -1 : 1)));
|
||||
const msg =
|
||||
err instanceof SocialError ? err.message : "Could not update like";
|
||||
toast.error(msg, { duration: 3000 });
|
||||
} finally {
|
||||
likeBusy.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onReshare() {
|
||||
// Optimistic flip first so the UI feels instant; revert on error.
|
||||
setReshared(true);
|
||||
@@ -105,14 +139,26 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ActionButton
|
||||
label="Like"
|
||||
onClick={() =>
|
||||
withAuth(() => {
|
||||
toast("Likes are coming soon", { duration: 2000 });
|
||||
})
|
||||
label={liked ? "Unlike" : "Like"}
|
||||
onClick={() => withAuth(onLikeToggle)}
|
||||
icon={
|
||||
liked ? (
|
||||
<HiHeart className={cn(ICON_CLASS, "text-red-500")} />
|
||||
) : (
|
||||
<HiOutlineHeart className={ICON_CLASS} />
|
||||
)
|
||||
}
|
||||
icon={<HiOutlineHeart className={ICON_CLASS} />}
|
||||
/>
|
||||
{likeCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLikesOpen(true)}
|
||||
aria-label={`${likeCount} ${likeCount === 1 ? "like" : "likes"}`}
|
||||
className="-ml-1 mr-1 rounded px-1 text-sm font-medium tabular-nums text-muted-foreground transition-colors hover:text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{likeCount}
|
||||
</button>
|
||||
) : null}
|
||||
<ActionButton
|
||||
label="Comment"
|
||||
onClick={() =>
|
||||
@@ -169,6 +215,11 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
||||
isMobile={isMobile}
|
||||
existingPostId={post.id}
|
||||
/>
|
||||
<PostLikesDrawer
|
||||
open={likesOpen}
|
||||
postId={post.id}
|
||||
onClose={() => setLikesOpen(false)}
|
||||
/>
|
||||
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
||||
no per-card instance lives here. The provider's
|
||||
openShareSheet swaps the active post under the hood. */}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Ban, Flag, MoreVertical, Trash2 } from "lucide-react";
|
||||
import { Ban, Copy, Flag, MoreVertical, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Drawer,
|
||||
@@ -41,6 +41,8 @@ import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||
import { ReportDialog, type ReportOption } from "@/components/social/report-dialog";
|
||||
import { createShortLink } from "@/lib/share";
|
||||
import { structuredReferenceForPost } from "@/lib/source-reference";
|
||||
|
||||
// Post report categories — mirrors the BFF allow-list (postReportCategories).
|
||||
const POST_REPORT_OPTIONS: ReportOption[] = [
|
||||
@@ -147,10 +149,45 @@ export function PostCardMenu({
|
||||
};
|
||||
}, [open, isMobile]);
|
||||
|
||||
// Copy the post's caption + source citation + a (short) link to the post —
|
||||
// the same payload the share sheet / footer produce. Available to every
|
||||
// viewer.
|
||||
async function copyCaption() {
|
||||
setOpen(false);
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
let url = "";
|
||||
if (authorHandle) {
|
||||
const longPath = `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`;
|
||||
const code = await createShortLink(longPath);
|
||||
url = code
|
||||
? new URL(`/share/${code}`, origin).toString()
|
||||
: new URL(longPath, origin).toString();
|
||||
}
|
||||
const ref = structuredReferenceForPost(post.source_url, post.source_title);
|
||||
const lines: string[] = [];
|
||||
if (post.caption) lines.push(`“${post.caption}”`, "");
|
||||
lines.push("Source:", ref.title);
|
||||
if (ref.detail) lines.push(ref.detail);
|
||||
if (url) lines.push(url);
|
||||
await navigator.clipboard.writeText(lines.join("\n"));
|
||||
toast.success("Caption copied");
|
||||
} catch {
|
||||
toast.error("Could not copy");
|
||||
}
|
||||
}
|
||||
|
||||
// Build the items list AFTER all hooks have run so a conditional
|
||||
// early-return doesn't break the rules-of-hooks contract on
|
||||
// re-renders where viewer state changes.
|
||||
const items: MenuItem[] = [];
|
||||
// Copy caption — first item for everyone (owner and non-owner alike).
|
||||
items.push({
|
||||
key: "copy",
|
||||
label: "Copy caption",
|
||||
icon: <Copy className="size-4" />,
|
||||
onClick: copyCaption,
|
||||
});
|
||||
if (isOwner) {
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* PostLikesDrawer — the "liked by" list for a post. A thin wrapper over the
|
||||
* generic virtualized UserListDrawer, wired to the post-likes endpoint.
|
||||
*/
|
||||
|
||||
import { posts } from "@/lib/social";
|
||||
import { UserListDrawer } from "@/components/social/user-list-drawer";
|
||||
|
||||
export function PostLikesDrawer({
|
||||
open,
|
||||
postId,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
postId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UserListDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Likes"
|
||||
depsKey={`likes:${postId}`}
|
||||
fetchPage={(params) => posts.likers(postId, params)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UserListDrawer — a generic, virtualized, paginated, searchable list of
|
||||
* users in a drawer (right on desktop, bottom sheet on mobile). Powers both
|
||||
* the followers/following lists and the post "liked by" list.
|
||||
*
|
||||
* The caller supplies `fetchPage(params)` returning `{ users, total }` and a
|
||||
* `depsKey` that changes when the data source changes (so the list reloads).
|
||||
* Search is debounced and queries the backend across the full set. The list
|
||||
* loads the next page as you scroll near the bottom.
|
||||
*
|
||||
* State-update notes (set-state-in-effect rule): the (re)load runs inside a
|
||||
* debounce timer callback and pagination runs from the scroll handler — never
|
||||
* synchronously inside an effect body.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import type { FollowList, FollowListParams, UserSummary } from "@/lib/social";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE = 30;
|
||||
const ROW = 64; // px — fixed row height for the virtualizer
|
||||
|
||||
export type FetchUserPage = (params: FollowListParams) => Promise<FollowList>;
|
||||
|
||||
export function UserListDrawer({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
depsKey,
|
||||
fetchPage,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
/** Changes when the data source changes (e.g. mode/handle or post id), to
|
||||
* trigger a fresh reload. */
|
||||
depsKey: string;
|
||||
fetchPage: FetchUserPage;
|
||||
}) {
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const direction = isMobile ? "bottom" : "right";
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<UserSummary[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
|
||||
// reqRef tags the current query "session"; a newer reload bumps it so any
|
||||
// in-flight reload/append for an older query is ignored on arrival.
|
||||
const reqRef = useRef(0);
|
||||
const busyRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Latest fetchPage, read via ref so its (unstable) identity isn't an effect
|
||||
// dep — the reload keys on `depsKey` instead.
|
||||
const fetchRef = useRef(fetchPage);
|
||||
fetchRef.current = fetchPage;
|
||||
|
||||
// (Re)load page 0 when the drawer opens or the source/query changes.
|
||||
// Debounced so typing in search doesn't spam the backend.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(() => {
|
||||
const token = ++reqRef.current;
|
||||
busyRef.current = true;
|
||||
setItems([]);
|
||||
setStatus("loading");
|
||||
fetchRef
|
||||
.current({ q, limit: PAGE, offset: 0 })
|
||||
.then((res) => {
|
||||
if (reqRef.current !== token) return;
|
||||
setItems(res.users);
|
||||
setTotal(res.total);
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch(() => {
|
||||
if (reqRef.current === token) setStatus("error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (reqRef.current === token) busyRef.current = false;
|
||||
});
|
||||
}, 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [open, depsKey, q]);
|
||||
|
||||
function loadMore() {
|
||||
if (busyRef.current || items.length >= total) return;
|
||||
const token = reqRef.current; // same session — don't bump
|
||||
const offset = items.length;
|
||||
busyRef.current = true;
|
||||
setStatus("loading");
|
||||
fetchRef
|
||||
.current({ q, limit: PAGE, offset })
|
||||
.then((res) => {
|
||||
if (reqRef.current !== token) return;
|
||||
setItems((prev) => [...prev, ...res.users]);
|
||||
setTotal(res.total);
|
||||
setStatus("idle");
|
||||
})
|
||||
.catch(() => {
|
||||
if (reqRef.current === token) setStatus("error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (reqRef.current === token) busyRef.current = false;
|
||||
});
|
||||
}
|
||||
|
||||
// TanStack Virtual returns non-memoizable functions; the React Compiler
|
||||
// simply skips memoizing this component, which is fine here.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const empty = status !== "loading" && items.length === 0;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
direction={direction}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
direction={direction}
|
||||
className={cn(
|
||||
"bg-card flex flex-col",
|
||||
isMobile ? "h-[85vh]" : "h-full w-[88vw] max-w-sm",
|
||||
)}
|
||||
>
|
||||
<DrawerHeader className="border-b border-border text-left">
|
||||
<DrawerTitle className="text-base">{title}</DrawerTitle>
|
||||
<div className="relative mt-2">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Search"
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</DrawerHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 240) {
|
||||
loadMore();
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
|
||||
>
|
||||
{status === "error" ? (
|
||||
<Message>Couldn't load. Please try again.</Message>
|
||||
) : empty ? (
|
||||
<Message>{q.trim() ? "No matches." : "Nothing here yet."}</Message>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vi) => {
|
||||
const u = items[vi.index];
|
||||
if (!u) return null;
|
||||
return (
|
||||
<div
|
||||
key={vi.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: vi.size,
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Row user={u} onNavigate={onClose} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "loading" ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
user,
|
||||
onNavigate,
|
||||
}: {
|
||||
user: UserSummary;
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/u/${user.handle}`}
|
||||
onClick={onNavigate}
|
||||
className="flex h-16 items-center gap-3 px-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<RowAvatar src={user.avatar_url} name={user.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="truncate text-sm text-muted-foreground">@{user.handle}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAvatar({ src, name }: { src: string | null; name: string }) {
|
||||
if (src) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="size-10 shrink-0 rounded-full object-cover ring-1 ring-foreground/10"
|
||||
unoptimized
|
||||
/>
|
||||
);
|
||||
}
|
||||
const initials =
|
||||
name
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase() || "?";
|
||||
return (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-semibold text-primary ring-1 ring-foreground/10">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
+31
@@ -22,6 +22,37 @@ export function pushSupported(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** iPhone/iPad/iPod (incl. iPadOS, which reports as "Macintosh" + touch). */
|
||||
export function isIOS(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const ua = navigator.userAgent;
|
||||
return (
|
||||
/iPad|iPhone|iPod/.test(ua) ||
|
||||
(navigator.maxTouchPoints > 1 && /Macintosh/.test(ua))
|
||||
);
|
||||
}
|
||||
|
||||
/** Running as an installed (Home Screen) app, not a browser tab. */
|
||||
export function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
// iOS Safari's non-standard flag…
|
||||
(navigator as Navigator & { standalone?: boolean }).standalone === true ||
|
||||
// …and the standard display-mode for everyone else.
|
||||
window.matchMedia("(display-mode: standalone)").matches
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS only allows Web Push when the site has been added to the Home Screen
|
||||
* (iOS 16.4+). In a plain Safari tab the push APIs don't exist, so this is
|
||||
* the case where we should guide the user to "Add to Home Screen" rather
|
||||
* than show a dead "unsupported".
|
||||
*/
|
||||
export function needsIOSInstall(): boolean {
|
||||
return isIOS() && !isStandalone() && !pushSupported();
|
||||
}
|
||||
|
||||
/** The browser's current notification permission, or "unsupported". */
|
||||
export function pushPermission(): NotificationPermission | "unsupported" {
|
||||
if (!pushSupported()) return "unsupported";
|
||||
|
||||
@@ -77,6 +77,18 @@ export type Post = {
|
||||
original_author_name?: string | null;
|
||||
original_author_handle?: string | null;
|
||||
original_author_avatar_url?: string | null;
|
||||
/** Effective like total = denormalized count + pending (un-flushed)
|
||||
* delta. Always present on hydrated reads (defaults to 0). */
|
||||
likes_count?: number;
|
||||
/** Whether the calling viewer has liked this post. Always false / absent
|
||||
* for anonymous viewers. */
|
||||
viewer_has_liked?: boolean;
|
||||
};
|
||||
|
||||
/** Result of a like / unlike toggle: the new viewer state + effective total. */
|
||||
export type LikeResult = {
|
||||
liked: boolean;
|
||||
likes_count: number;
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
@@ -198,6 +210,22 @@ export const posts = {
|
||||
unreshare(id: string) {
|
||||
return call<void>(`/posts/${id}/reshare`, { method: "DELETE" });
|
||||
},
|
||||
/** Like a post (idempotent). Returns the new viewer state + effective
|
||||
* total (DB count + pending Redis delta). Auth-required. */
|
||||
like(id: string) {
|
||||
return call<LikeResult>(`/posts/${id}/like`, { method: "POST" });
|
||||
},
|
||||
/** Unlike a post (idempotent). Same shape as `like`. */
|
||||
unlike(id: string) {
|
||||
return call<LikeResult>(`/posts/${id}/like`, { method: "DELETE" });
|
||||
},
|
||||
/** One page of users who liked the post (newest first), optional
|
||||
* name/handle search. `total` is the full matching count. Public. */
|
||||
likers(id: string, opts: FollowListParams = {}) {
|
||||
return call<FollowList>(
|
||||
`/posts/${id}/likes?${followListQuery(opts)}`,
|
||||
);
|
||||
},
|
||||
/** Report a post. category from PostReportCategory; reason optional.
|
||||
* Auth-required (401 for anonymous callers). Returns nothing (204). */
|
||||
report(id: string, category: PostReportCategory, reason: string) {
|
||||
@@ -417,6 +445,10 @@ export type AppNotification = {
|
||||
actor_name?: string | null;
|
||||
actor_handle?: string | null;
|
||||
actor_avatar_url?: string | null;
|
||||
/** For aggregated `post_like` rows: the liked post + how many likers the
|
||||
* row represents (renders "X and N-1 others liked your post"). */
|
||||
post_id?: string | null;
|
||||
actor_count?: number;
|
||||
};
|
||||
|
||||
export const notifications = {
|
||||
@@ -493,4 +525,42 @@ export const relationships = {
|
||||
body: JSON.stringify({ category, reason }),
|
||||
});
|
||||
},
|
||||
|
||||
/** One page of the profile's accepted followers, optionally filtered by a
|
||||
* name/handle search. `total` is the full matching count for pagination. */
|
||||
followers(handle: string, opts: FollowListParams = {}) {
|
||||
return call<FollowList>(
|
||||
`/u/${encodeURIComponent(handle)}/followers?${followListQuery(opts)}`,
|
||||
);
|
||||
},
|
||||
/** One page of the users this profile follows (accepted), same shape. */
|
||||
following(handle: string, opts: FollowListParams = {}) {
|
||||
return call<FollowList>(
|
||||
`/u/${encodeURIComponent(handle)}/following?${followListQuery(opts)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Lightweight user row in a followers / following list. */
|
||||
export type UserSummary = {
|
||||
handle: string;
|
||||
name: string;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
/** A paginated followers/following page: the rows + total matching count. */
|
||||
export type FollowList = {
|
||||
users: UserSummary[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type FollowListParams = { q?: string; limit?: number; offset?: number };
|
||||
|
||||
function followListQuery({ q, limit = 30, offset = 0 }: FollowListParams) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
if (q && q.trim()) params.set("q", q.trim());
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+59
-28
@@ -1,8 +1,9 @@
|
||||
/* 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 }.
|
||||
* an existing same-origin tab and navigates it, or opens a new one). The push
|
||||
* payload is the JSON the BFF sends:
|
||||
* { title, body, url, icon, tag, require_interaction }
|
||||
*/
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
@@ -11,38 +12,68 @@ self.addEventListener("activate", (event) =>
|
||||
);
|
||||
|
||||
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));
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
let data = {};
|
||||
if (event.data) {
|
||||
try {
|
||||
// event.data.json() is synchronous; text() is a Promise — only
|
||||
// awaited in the fallback.
|
||||
data = event.data.json();
|
||||
} catch {
|
||||
data = { body: await event.data.text() };
|
||||
}
|
||||
}
|
||||
|
||||
const title = data.title || "Prabhupāda Says";
|
||||
const options = {
|
||||
body: data.body || "",
|
||||
icon: data.icon || "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
// A tag dedupes a series (e.g. daily reminders replace the previous
|
||||
// one); renotify re-alerts when a tagged notification is replaced.
|
||||
tag: data.tag || undefined,
|
||||
renotify: !!data.tag,
|
||||
// Reminders set this so they stay until acted on (desktop otherwise
|
||||
// auto-dismisses). Defaults off for everything else.
|
||||
requireInteraction: data.require_interaction === true,
|
||||
vibrate: [100, 50, 100],
|
||||
data: { url: data.url || "/" },
|
||||
actions: [{ action: "open", title: "Open" }],
|
||||
};
|
||||
await self.registration.showNotification(title, options);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url =
|
||||
(event.notification.data && event.notification.data.url) || "/";
|
||||
// The only action is "open"; the default body click behaves the same.
|
||||
const url = (event.notification.data && event.notification.data.url) || "/";
|
||||
const target = new URL(url, self.location.origin).href;
|
||||
|
||||
event.waitUntil(
|
||||
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();
|
||||
(async () => {
|
||||
const list = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
// Reuse a single existing same-origin tab — focus it and navigate only
|
||||
// that one (not every matched window).
|
||||
for (const client of list) {
|
||||
if (client.url.startsWith(self.location.origin)) {
|
||||
await client.focus();
|
||||
if ("navigate" in client) {
|
||||
try {
|
||||
await client.navigate(target);
|
||||
} catch {
|
||||
/* cross-document navigation can reject; focus already happened */
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(url);
|
||||
}),
|
||||
}
|
||||
if (self.clients.openWindow) await self.clients.openWindow(target);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1211,6 +1211,18 @@
|
||||
postcss "^8.5.6"
|
||||
tailwindcss "4.2.2"
|
||||
|
||||
"@tanstack/react-virtual@^3.14.2":
|
||||
version "3.14.2"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz#ff1f97cd52f2bf4bda7f6d141bc9af80010daf45"
|
||||
integrity sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==
|
||||
dependencies:
|
||||
"@tanstack/virtual-core" "3.17.0"
|
||||
|
||||
"@tanstack/virtual-core@3.17.0":
|
||||
version "3.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz#2949d4aaeaf73f89bab10bf86e22905f5a668acc"
|
||||
integrity sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==
|
||||
|
||||
"@ts-morph/common@~0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user