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";
|
||||
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
@@ -19,7 +18,6 @@ export function ContinueWatchingList({
|
||||
return (
|
||||
<Carousel
|
||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-4 sm:-mx-0"
|
||||
>
|
||||
<CarouselContent className="px-4 sm:px-0">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import { createStore, Provider, useAtom, useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { TitleCardSkeleton } from "@/components/skeletons";
|
||||
@@ -162,7 +161,6 @@ function FilterableTitleRowInner({
|
||||
dragFree: true,
|
||||
containScroll: "trimSnaps",
|
||||
}}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-6 sm:-mx-2 carousel-tilt"
|
||||
>
|
||||
<CarouselContent className="px-6 sm:px-2">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import { TitleCard } from "@/components/title-card";
|
||||
import {
|
||||
Carousel,
|
||||
@@ -44,7 +43,6 @@ export function TitleRow({
|
||||
</div>
|
||||
<Carousel
|
||||
opts={{ align: "start", dragFree: true, containScroll: "trimSnaps" }}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-6 sm:-mx-2 carousel-tilt"
|
||||
>
|
||||
<CarouselContent className="px-6 sm:px-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconDoorEnter } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useOptimistic, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -15,21 +15,20 @@ export function RegistrationSection({
|
||||
const [registrationOpen, setRegistrationOpen] = useState(
|
||||
initialRegistrationOpen,
|
||||
);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [optimisticOpen, setOptimisticOpen] = useOptimistic(registrationOpen);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
async function handleToggle(checked: boolean) {
|
||||
const previous = registrationOpen;
|
||||
setRegistrationOpen(checked);
|
||||
setToggling(true);
|
||||
try {
|
||||
await toggleRegistration(checked);
|
||||
toast.success(checked ? "Registration opened" : "Registration closed");
|
||||
} catch {
|
||||
setRegistrationOpen(previous);
|
||||
toast.error("Failed to update registration setting");
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
function handleToggle(checked: boolean) {
|
||||
startTransition(async () => {
|
||||
setOptimisticOpen(checked);
|
||||
try {
|
||||
await toggleRegistration(checked);
|
||||
setRegistrationOpen(checked);
|
||||
toast.success(checked ? "Registration opened" : "Registration closed");
|
||||
} catch {
|
||||
toast.error("Failed to update registration setting");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -47,9 +46,9 @@ export function RegistrationSection({
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={registrationOpen}
|
||||
checked={optimisticOpen}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={toggling}
|
||||
disabled={isPending}
|
||||
aria-label="Toggle open registration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconWorldUpload } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { useOptimistic, useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -13,23 +13,22 @@ export function UpdateCheckSection({
|
||||
initialEnabled: boolean;
|
||||
}) {
|
||||
const [enabled, setEnabled] = useState(initialEnabled);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
async function handleToggle(checked: boolean) {
|
||||
const previous = enabled;
|
||||
setEnabled(checked);
|
||||
setToggling(true);
|
||||
try {
|
||||
await toggleUpdateCheck(checked);
|
||||
toast.success(
|
||||
checked ? "Update checks enabled" : "Update checks disabled",
|
||||
);
|
||||
} catch {
|
||||
setEnabled(previous);
|
||||
toast.error("Failed to update setting");
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
function handleToggle(checked: boolean) {
|
||||
startTransition(async () => {
|
||||
setOptimisticEnabled(checked);
|
||||
try {
|
||||
await toggleUpdateCheck(checked);
|
||||
setEnabled(checked);
|
||||
toast.success(
|
||||
checked ? "Update checks enabled" : "Update checks disabled",
|
||||
);
|
||||
} catch {
|
||||
toast.error("Failed to update setting");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -50,9 +49,9 @@ export function UpdateCheckSection({
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
checked={optimisticEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={toggling}
|
||||
disabled={isPending}
|
||||
aria-label="Toggle automatic update checks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IconUser, IconUsers } from "@tabler/icons-react";
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -31,7 +30,6 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
|
||||
dragFree: true,
|
||||
containScroll: "trimSnaps",
|
||||
}}
|
||||
plugins={[WheelGesturesPlugin({ forceWheelAxis: "x" })]}
|
||||
className="-mx-4 sm:-mx-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 { useState } from "react";
|
||||
|
||||
const springTransition = {
|
||||
type: "spring" as const,
|
||||
stiffness: 400,
|
||||
damping: 15,
|
||||
};
|
||||
|
||||
interface StarRatingProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
@@ -36,11 +42,7 @@ export function StarRating({ value, onChange }: StarRatingProps) {
|
||||
animate={
|
||||
filled && star === value ? { scale: [1, 1.25, 1] } : { scale: 1 }
|
||||
}
|
||||
transition={{
|
||||
type: "spring" as const,
|
||||
stiffness: 400,
|
||||
damping: 15,
|
||||
}}
|
||||
transition={springTransition}
|
||||
>
|
||||
{filled ? (
|
||||
<IconStarFilled className="size-4.5 text-primary" />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { format, parseISO } from "date-fns";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -50,6 +50,7 @@ export function TitleSeasons({
|
||||
|
||||
const seasons = useAtomValue(seasonsAtom);
|
||||
const episodeWatches = useAtomValue(episodeWatchesAtom);
|
||||
const watchedSet = useMemo(() => new Set(episodeWatches), [episodeWatches]);
|
||||
const userStatus = useAtomValue(userStatusAtom);
|
||||
const watchingEp = useAtomValue(watchingEpAtom);
|
||||
const {
|
||||
@@ -58,6 +59,18 @@ export function TitleSeasons({
|
||||
handleUnmarkSeason,
|
||||
handleMarkAllWatched,
|
||||
} = 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 [markAllOpen, setMarkAllOpen] = useState(false);
|
||||
|
||||
@@ -110,9 +123,7 @@ export function TitleSeasons({
|
||||
<div className="space-y-2">
|
||||
{seasons.map((season) => {
|
||||
const isOpen = openSeason === season.seasonNumber;
|
||||
const watchedCount = season.episodes.filter((ep) =>
|
||||
episodeWatches.includes(ep.id),
|
||||
).length;
|
||||
const watchedCount = seasonProgress.get(season.id) ?? 0;
|
||||
const totalCount = season.episodes.length;
|
||||
const progressPercent =
|
||||
totalCount > 0 ? (watchedCount / totalCount) * 100 : 0;
|
||||
@@ -207,7 +218,7 @@ export function TitleSeasons({
|
||||
className="overflow-hidden border-t border-border/50"
|
||||
>
|
||||
{season.episodes.map((ep) => {
|
||||
const isWatched = episodeWatches.includes(ep.id);
|
||||
const isWatched = watchedSet.has(ep.id);
|
||||
const { stillPath } = ep;
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -44,6 +44,15 @@ import {
|
||||
} from "@/lib/atoms/command-palette";
|
||||
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];
|
||||
|
||||
export function CommandPalette() {
|
||||
@@ -102,7 +111,7 @@ export function CommandPalette() {
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
}, [debouncedQuery, results, setRecentSearches]);
|
||||
}, [debouncedQuery, results.length, setRecentSearches]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(result: SearchResult) => {
|
||||
@@ -134,16 +143,6 @@ export function CommandPalette() {
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||
@@ -362,7 +361,7 @@ export function CommandPalette() {
|
||||
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{scope}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const defaultPlugins = [WheelGesturesPlugin({ forceWheelAxis: "x" })];
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
@@ -55,7 +58,7 @@ function Carousel({
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
plugins ?? defaultPlugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
+7
-1
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
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 {
|
||||
results: data?.results?.slice(0, 8) ?? [],
|
||||
results,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,11 +34,17 @@ export function useTiltEffect(config: TiltConfig = {}) {
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setDisabled(mql.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setDisabled(e.matches);
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const finePointer = window.matchMedia("(pointer: fine)");
|
||||
const update = () =>
|
||||
setDisabled(reducedMotion.matches || !finePointer.matches);
|
||||
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
|
||||
|
||||
+32
-21
@@ -1,5 +1,32 @@
|
||||
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 {
|
||||
if (!date) return null;
|
||||
@@ -9,26 +36,10 @@ function toTimestamp(date: string | Date | null | undefined): number | null {
|
||||
|
||||
export function useTimeAgo(
|
||||
date: string | Date | null | undefined,
|
||||
{ intervalMs = 1_000, addSuffix = true, fallback = "" } = {},
|
||||
{ addSuffix = true, fallback = "" } = {},
|
||||
): string {
|
||||
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const ts = toTimestamp(date);
|
||||
|
||||
const [text, setText] = useState(() =>
|
||||
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;
|
||||
if (ts === null) return fallback;
|
||||
return formatDistanceToNowStrict(ts, { addSuffix });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user