mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Optimize frontend: memoization, modern React hooks, reduced render overhead
- useTimeAgo: replace per-instance intervals with shared useSyncExternalStore ticker - useTiltEffect: gate to fine-pointer devices, skip motion graphs on touch - Carousel: bake WheelGesturesPlugin into primitive, remove from all consumers - TitleSeasons: use memoized Set + precomputed progress map instead of Array.includes - useSearch: stabilize results identity with useMemo - CommandPalette: hoist shortcut grouping to module scope, stabilize effect deps - StarRating: hoist static spring transition to module constant - Settings toggles: use useOptimistic + useTransition for auto-rollback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
|
||||||
import {
|
import {
|
||||||
Carousel,
|
Carousel,
|
||||||
CarouselContent,
|
CarouselContent,
|
||||||
@@ -19,7 +18,6 @@ export function ContinueWatchingList({
|
|||||||
return (
|
return (
|
||||||
<Carousel
|
<Carousel
|
||||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
|
||||||
className="-mx-4 sm:-mx-0"
|
className="-mx-4 sm:-mx-0"
|
||||||
>
|
>
|
||||||
<CarouselContent className="px-4 sm:px-0">
|
<CarouselContent className="px-4 sm:px-0">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
|
||||||
import { createStore, Provider, useAtom, useAtomValue } from "jotai";
|
import { createStore, Provider, useAtom, useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TitleCardSkeleton } from "@/components/skeletons";
|
import { TitleCardSkeleton } from "@/components/skeletons";
|
||||||
@@ -162,7 +161,6 @@ function FilterableTitleRowInner({
|
|||||||
dragFree: true,
|
dragFree: true,
|
||||||
containScroll: "trimSnaps",
|
containScroll: "trimSnaps",
|
||||||
}}
|
}}
|
||||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
|
||||||
className="-mx-6 sm:-mx-2 carousel-tilt"
|
className="-mx-6 sm:-mx-2 carousel-tilt"
|
||||||
>
|
>
|
||||||
<CarouselContent className="px-6 sm:px-2">
|
<CarouselContent className="px-6 sm:px-2">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
|
||||||
import { TitleCard } from "@/components/title-card";
|
import { TitleCard } from "@/components/title-card";
|
||||||
import {
|
import {
|
||||||
Carousel,
|
Carousel,
|
||||||
@@ -44,7 +43,6 @@ export function TitleRow({
|
|||||||
</div>
|
</div>
|
||||||
<Carousel
|
<Carousel
|
||||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
|
||||||
className="-mx-6 sm:-mx-2 carousel-tilt"
|
className="-mx-6 sm:-mx-2 carousel-tilt"
|
||||||
>
|
>
|
||||||
<CarouselContent className="px-6 sm:px-2">
|
<CarouselContent className="px-6 sm:px-2">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconDoorEnter } from "@tabler/icons-react";
|
import { IconDoorEnter } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useOptimistic, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -15,21 +15,20 @@ export function RegistrationSection({
|
|||||||
const [registrationOpen, setRegistrationOpen] = useState(
|
const [registrationOpen, setRegistrationOpen] = useState(
|
||||||
initialRegistrationOpen,
|
initialRegistrationOpen,
|
||||||
);
|
);
|
||||||
const [toggling, setToggling] = useState(false);
|
const [optimisticOpen, setOptimisticOpen] = useOptimistic(registrationOpen);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
async function handleToggle(checked: boolean) {
|
function handleToggle(checked: boolean) {
|
||||||
const previous = registrationOpen;
|
startTransition(async () => {
|
||||||
setRegistrationOpen(checked);
|
setOptimisticOpen(checked);
|
||||||
setToggling(true);
|
try {
|
||||||
try {
|
await toggleRegistration(checked);
|
||||||
await toggleRegistration(checked);
|
setRegistrationOpen(checked);
|
||||||
toast.success(checked ? "Registration opened" : "Registration closed");
|
toast.success(checked ? "Registration opened" : "Registration closed");
|
||||||
} catch {
|
} catch {
|
||||||
setRegistrationOpen(previous);
|
toast.error("Failed to update registration setting");
|
||||||
toast.error("Failed to update registration setting");
|
}
|
||||||
} finally {
|
});
|
||||||
setToggling(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,9 +46,9 @@ export function RegistrationSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={registrationOpen}
|
checked={optimisticOpen}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={toggling}
|
disabled={isPending}
|
||||||
aria-label="Toggle open registration"
|
aria-label="Toggle open registration"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconWorldUpload } from "@tabler/icons-react";
|
import { IconWorldUpload } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useOptimistic, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -13,23 +13,22 @@ export function UpdateCheckSection({
|
|||||||
initialEnabled: boolean;
|
initialEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [enabled, setEnabled] = useState(initialEnabled);
|
const [enabled, setEnabled] = useState(initialEnabled);
|
||||||
const [toggling, setToggling] = useState(false);
|
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
async function handleToggle(checked: boolean) {
|
function handleToggle(checked: boolean) {
|
||||||
const previous = enabled;
|
startTransition(async () => {
|
||||||
setEnabled(checked);
|
setOptimisticEnabled(checked);
|
||||||
setToggling(true);
|
try {
|
||||||
try {
|
await toggleUpdateCheck(checked);
|
||||||
await toggleUpdateCheck(checked);
|
setEnabled(checked);
|
||||||
toast.success(
|
toast.success(
|
||||||
checked ? "Update checks enabled" : "Update checks disabled",
|
checked ? "Update checks enabled" : "Update checks disabled",
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setEnabled(previous);
|
toast.error("Failed to update setting");
|
||||||
toast.error("Failed to update setting");
|
}
|
||||||
} finally {
|
});
|
||||||
setToggling(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,9 +49,9 @@ export function UpdateCheckSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={optimisticEnabled}
|
||||||
onCheckedChange={handleToggle}
|
onCheckedChange={handleToggle}
|
||||||
disabled={toggling}
|
disabled={isPending}
|
||||||
aria-label="Toggle automatic update checks"
|
aria-label="Toggle automatic update checks"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconUser, IconUsers } from "@tabler/icons-react";
|
import { IconUser, IconUsers } from "@tabler/icons-react";
|
||||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +30,6 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
|||||||
dragFree: true,
|
dragFree: true,
|
||||||
containScroll: "trimSnaps",
|
containScroll: "trimSnaps",
|
||||||
}}
|
}}
|
||||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
|
||||||
className="-mx-4 sm:-mx-0"
|
className="-mx-4 sm:-mx-0"
|
||||||
>
|
>
|
||||||
<CarouselContent className="px-4 sm:px-0">
|
<CarouselContent className="px-4 sm:px-0">
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import { IconStar, IconStarFilled } from "@tabler/icons-react";
|
|||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const springTransition = {
|
||||||
|
type: "spring" as const,
|
||||||
|
stiffness: 400,
|
||||||
|
damping: 15,
|
||||||
|
};
|
||||||
|
|
||||||
interface StarRatingProps {
|
interface StarRatingProps {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
@@ -36,11 +42,7 @@ export function StarRating({ value, onChange }: StarRatingProps) {
|
|||||||
animate={
|
animate={
|
||||||
filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }
|
filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }
|
||||||
}
|
}
|
||||||
transition={{
|
transition={springTransition}
|
||||||
type: "spring" as const,
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 15,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{filled ? (
|
{filled ? (
|
||||||
<IconStarFilled className="size-4.5 text-primary" />
|
<IconStarFilled className="size-4.5 text-primary" />
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { format, parseISO } from "date-fns";
|
|||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -50,6 +50,7 @@ export function TitleSeasons({
|
|||||||
|
|
||||||
const seasons = useAtomValue(seasonsAtom);
|
const seasons = useAtomValue(seasonsAtom);
|
||||||
const episodeWatches = useAtomValue(episodeWatchesAtom);
|
const episodeWatches = useAtomValue(episodeWatchesAtom);
|
||||||
|
const watchedSet = useMemo(() => new Set(episodeWatches), [episodeWatches]);
|
||||||
const userStatus = useAtomValue(userStatusAtom);
|
const userStatus = useAtomValue(userStatusAtom);
|
||||||
const watchingEp = useAtomValue(watchingEpAtom);
|
const watchingEp = useAtomValue(watchingEpAtom);
|
||||||
const {
|
const {
|
||||||
@@ -58,6 +59,18 @@ export function TitleSeasons({
|
|||||||
handleUnmarkSeason,
|
handleUnmarkSeason,
|
||||||
handleMarkAllWatched,
|
handleMarkAllWatched,
|
||||||
} = useTitleActions();
|
} = useTitleActions();
|
||||||
|
const seasonProgress = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const season of seasons) {
|
||||||
|
let count = 0;
|
||||||
|
for (const ep of season.episodes) {
|
||||||
|
if (watchedSet.has(ep.id)) count++;
|
||||||
|
}
|
||||||
|
map.set(season.id, count);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [seasons, watchedSet]);
|
||||||
|
|
||||||
const [openSeason, setOpenSeason] = useState<number | null>(null);
|
const [openSeason, setOpenSeason] = useState<number | null>(null);
|
||||||
const [markAllOpen, setMarkAllOpen] = useState(false);
|
const [markAllOpen, setMarkAllOpen] = useState(false);
|
||||||
|
|
||||||
@@ -110,9 +123,7 @@ export function TitleSeasons({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{seasons.map((season) => {
|
{seasons.map((season) => {
|
||||||
const isOpen = openSeason === season.seasonNumber;
|
const isOpen = openSeason === season.seasonNumber;
|
||||||
const watchedCount = season.episodes.filter((ep) =>
|
const watchedCount = seasonProgress.get(season.id) ?? 0;
|
||||||
episodeWatches.includes(ep.id),
|
|
||||||
).length;
|
|
||||||
const totalCount = season.episodes.length;
|
const totalCount = season.episodes.length;
|
||||||
const progressPercent =
|
const progressPercent =
|
||||||
totalCount > 0 ? (watchedCount / totalCount) * 100 : 0;
|
totalCount > 0 ? (watchedCount / totalCount) * 100 : 0;
|
||||||
@@ -207,7 +218,7 @@ export function TitleSeasons({
|
|||||||
className="overflow-hidden border-t border-border/50"
|
className="overflow-hidden border-t border-border/50"
|
||||||
>
|
>
|
||||||
{season.episodes.map((ep) => {
|
{season.episodes.map((ep) => {
|
||||||
const isWatched = episodeWatches.includes(ep.id);
|
const isWatched = watchedSet.has(ep.id);
|
||||||
const { stillPath } = ep;
|
const { stillPath } = ep;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ import {
|
|||||||
} from "@/lib/atoms/command-palette";
|
} from "@/lib/atoms/command-palette";
|
||||||
import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts";
|
import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts";
|
||||||
|
|
||||||
|
const groupedShortcuts: Record<
|
||||||
|
string,
|
||||||
|
{ description: string; keys: readonly string[] }[]
|
||||||
|
> = {};
|
||||||
|
for (const entry of SHORTCUT_DESCRIPTIONS) {
|
||||||
|
if (!groupedShortcuts[entry.scope]) groupedShortcuts[entry.scope] = [];
|
||||||
|
groupedShortcuts[entry.scope].push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
type SearchResult = ReturnType<typeof useSearch>["results"][number];
|
type SearchResult = ReturnType<typeof useSearch>["results"][number];
|
||||||
|
|
||||||
export function CommandPalette() {
|
export function CommandPalette() {
|
||||||
@@ -102,7 +111,7 @@ export function CommandPalette() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
};
|
};
|
||||||
}, [debouncedQuery, results, setRecentSearches]);
|
}, [debouncedQuery, results.length, setRecentSearches]);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(result: SearchResult) => {
|
(result: SearchResult) => {
|
||||||
@@ -134,16 +143,6 @@ export function CommandPalette() {
|
|||||||
|
|
||||||
const hasQuery = query.trim().length > 0;
|
const hasQuery = query.trim().length > 0;
|
||||||
|
|
||||||
// Group shortcuts by scope for help dialog
|
|
||||||
const grouped: Record<
|
|
||||||
string,
|
|
||||||
{ description: string; keys: readonly string[] }[]
|
|
||||||
> = {};
|
|
||||||
for (const entry of SHORTCUT_DESCRIPTIONS) {
|
|
||||||
if (!grouped[entry.scope]) grouped[entry.scope] = [];
|
|
||||||
grouped[entry.scope].push(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||||
@@ -362,7 +361,7 @@ export function CommandPalette() {
|
|||||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-5 py-2">
|
<div className="space-y-5 py-2">
|
||||||
{Object.entries(grouped).map(([scope, items]) => (
|
{Object.entries(groupedShortcuts).map(([scope, items]) => (
|
||||||
<div key={scope} className="space-y-2">
|
<div key={scope} className="space-y-2">
|
||||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{scope}
|
{scope}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
|||||||
import useEmblaCarousel, {
|
import useEmblaCarousel, {
|
||||||
type UseEmblaCarouselType,
|
type UseEmblaCarouselType,
|
||||||
} from "embla-carousel-react";
|
} from "embla-carousel-react";
|
||||||
|
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const defaultPlugins = [WheelGesturesPlugin({ forceWheelAxis: "x" })];
|
||||||
|
|
||||||
type CarouselApi = UseEmblaCarouselType[1];
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
type CarouselOptions = UseCarouselParameters[0];
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
@@ -55,7 +58,7 @@ function Carousel({
|
|||||||
...opts,
|
...opts,
|
||||||
axis: orientation === "horizontal" ? "x" : "y",
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
},
|
},
|
||||||
plugins,
|
plugins ?? defaultPlugins,
|
||||||
);
|
);
|
||||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|||||||
+7
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { fetcher } from "@/lib/swr/fetcher";
|
import { fetcher } from "@/lib/swr/fetcher";
|
||||||
|
|
||||||
@@ -26,8 +27,13 @@ export function useSearch(debouncedQuery: string) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const results = useMemo(
|
||||||
|
() => data?.results?.slice(0, 8) ?? [],
|
||||||
|
[data?.results],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results: data?.results?.slice(0, 8) ?? [],
|
results,
|
||||||
isLoading,
|
isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,17 @@ export function useTiltEffect(config: TiltConfig = {}) {
|
|||||||
const [disabled, setDisabled] = useState(false);
|
const [disabled, setDisabled] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
setDisabled(mql.matches);
|
const finePointer = window.matchMedia("(pointer: fine)");
|
||||||
const handler = (e: MediaQueryListEvent) => setDisabled(e.matches);
|
const update = () =>
|
||||||
mql.addEventListener("change", handler);
|
setDisabled(reducedMotion.matches || !finePointer.matches);
|
||||||
return () => mql.removeEventListener("change", handler);
|
update();
|
||||||
|
reducedMotion.addEventListener("change", update);
|
||||||
|
finePointer.addEventListener("change", update);
|
||||||
|
return () => {
|
||||||
|
reducedMotion.removeEventListener("change", update);
|
||||||
|
finePointer.removeEventListener("change", update);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Normalized mouse position [0,1], center = 0.5
|
// Normalized mouse position [0,1], center = 0.5
|
||||||
|
|||||||
+32
-21
@@ -1,5 +1,32 @@
|
|||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { formatDistanceToNowStrict } from "date-fns";
|
||||||
import { useEffect, useEffectEvent, useState } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
// Shared ticker — one interval regardless of how many components subscribe
|
||||||
|
const TICK_MS = 30_000;
|
||||||
|
let tick = Date.now();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let timerId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
timerId = setInterval(() => {
|
||||||
|
tick = Date.now();
|
||||||
|
for (const l of listeners) l();
|
||||||
|
}, TICK_MS);
|
||||||
|
}
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(cb);
|
||||||
|
if (listeners.size === 0 && timerId) {
|
||||||
|
clearInterval(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot() {
|
||||||
|
return tick;
|
||||||
|
}
|
||||||
|
|
||||||
function toTimestamp(date: string | Date | null | undefined): number | null {
|
function toTimestamp(date: string | Date | null | undefined): number | null {
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
@@ -9,26 +36,10 @@ function toTimestamp(date: string | Date | null | undefined): number | null {
|
|||||||
|
|
||||||
export function useTimeAgo(
|
export function useTimeAgo(
|
||||||
date: string | Date | null | undefined,
|
date: string | Date | null | undefined,
|
||||||
{ intervalMs = 1_000, addSuffix = true, fallback = "" } = {},
|
{ addSuffix = true, fallback = "" } = {},
|
||||||
): string {
|
): string {
|
||||||
|
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
const ts = toTimestamp(date);
|
const ts = toTimestamp(date);
|
||||||
|
if (ts === null) return fallback;
|
||||||
const [text, setText] = useState(() =>
|
return formatDistanceToNowStrict(ts, { addSuffix });
|
||||||
ts === null ? fallback : formatDistanceToNowStrict(ts, { addSuffix }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const tick = useEffectEvent(() => {
|
|
||||||
const next =
|
|
||||||
ts === null ? fallback : formatDistanceToNowStrict(ts, { addSuffix });
|
|
||||||
if (next !== text) setText(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
tick();
|
|
||||||
if (ts === null) return;
|
|
||||||
const id = setInterval(tick, intervalMs);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [ts, intervalMs]); // tick is NOT listed — that's the point
|
|
||||||
|
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user