mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
53
apps/web-react/app/hooks/intl/useDateTimeFormat.ts
Normal file
53
apps/web-react/app/hooks/intl/useDateTimeFormat.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { useUserIntlPreference } from "./useUserIntlPreference";
|
||||
|
||||
/**
|
||||
* Zero-width space rendered (hidden via `invisible`) during SSR so the element
|
||||
* reserves exactly one normal text line, avoiding layout shift on hydration
|
||||
* without an empty box's baseline quirks.
|
||||
*/
|
||||
const SSR_PLACEHOLDER = "\u200b";
|
||||
|
||||
const SSR_FORMATTER = {
|
||||
format: (_date: Date | number) => SSR_PLACEHOLDER,
|
||||
formatRange: (_from: Date | number, _to: Date | number) => SSR_PLACEHOLDER,
|
||||
};
|
||||
|
||||
/**
|
||||
* SSR-safe wrapper around `Intl.DateTimeFormat`.
|
||||
*
|
||||
* Uses the user's locale and hour cycle preferences via `useUserIntlPreference`.
|
||||
* Before hydration the returned formatter's methods return a zero-width space
|
||||
* placeholder so that server output matches the initial client render while
|
||||
* reserving one text line.
|
||||
*
|
||||
* Inputs accept either a `Date` or a database timestamp (`number`); numbers
|
||||
* are converted via `databaseTimestampToDate`.
|
||||
*/
|
||||
export function useDateTimeFormat(options: Intl.DateTimeFormatOptions) {
|
||||
const { language, hourCycle, isLoaded } = useUserIntlPreference();
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(language, {
|
||||
...options,
|
||||
...(options.hour && hourCycle ? { hourCycle } : {}),
|
||||
});
|
||||
|
||||
const realFormatter = {
|
||||
format: (date: Date | number) => {
|
||||
return formatter.format(
|
||||
typeof date === "number" ? databaseTimestampToDate(date) : date,
|
||||
);
|
||||
},
|
||||
formatRange: (from: Date | number, to: Date | number) => {
|
||||
return formatter.formatRange(
|
||||
typeof from === "number" ? databaseTimestampToDate(from) : from,
|
||||
typeof to === "number" ? databaseTimestampToDate(to) : to,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
formatter: isLoaded ? realFormatter : SSR_FORMATTER,
|
||||
isLoaded,
|
||||
};
|
||||
}
|
||||
35
apps/web-react/app/hooks/intl/useFormatDistanceToNow.ts
Normal file
35
apps/web-react/app/hooks/intl/useFormatDistanceToNow.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
formatDistanceToNow as formatDistanceToNowUtil,
|
||||
} from "~/utils/dates";
|
||||
|
||||
/**
|
||||
* Hook that returns a `formatDistanceToNow` function (date-fns) bound to the
|
||||
* current site language, for locale-aware "x ago" / "in x" output.
|
||||
*
|
||||
* Accepts either a `Date` or a database timestamp (`number`); numbers are
|
||||
* converted via `databaseTimestampToDate`.
|
||||
*
|
||||
* Note: this intentionally does NOT honor the user's "always use browser
|
||||
* language" preference (unlike `useDateTimeFormat`). The browser may be set to
|
||||
* a language we have not loaded a date-fns locale for, so we use the
|
||||
* site language to guarantee a translated result.
|
||||
*/
|
||||
export function useFormatDistanceToNow() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
date: Date | number,
|
||||
options?: Omit<Parameters<typeof formatDistanceToNowUtil>[1], "language">,
|
||||
) => {
|
||||
return formatDistanceToNowUtil(
|
||||
typeof date === "number" ? databaseTimestampToDate(date) : date,
|
||||
{
|
||||
...options,
|
||||
language: i18n.language as LanguageCode,
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
45
apps/web-react/app/hooks/intl/useUserIntlPreference.ts
Normal file
45
apps/web-react/app/hooks/intl/useUserIntlPreference.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UserPreferences } from "~/db/tables-json";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useHydrated } from "../useHydrated";
|
||||
|
||||
/**
|
||||
* Resolves the language and hour cycle to use when formatting dates and times
|
||||
* for the current user. Prefers a browser language sharing a base tag with the
|
||||
* active i18n language (e.g. `en-GB` over site `en`) for regional formatting.
|
||||
* `isLoaded` is `false` until hydration; gate locale-dependent output on it to
|
||||
* avoid hydration mismatches.
|
||||
*/
|
||||
export function useUserIntlPreference() {
|
||||
const { i18n } = useTranslation();
|
||||
const user = useUser();
|
||||
const hydrated = useHydrated();
|
||||
|
||||
const browserLanguages = hydrated ? navigator.languages : [];
|
||||
|
||||
// does the user want to use their browser language even if the site is in another language?
|
||||
const language =
|
||||
browserLanguages.find((lang) => compareLanguages(lang, i18n.language)) ??
|
||||
i18n.language;
|
||||
|
||||
return {
|
||||
language,
|
||||
hourCycle: resolveHourCycle(user?.preferences?.clockFormat),
|
||||
isLoaded: hydrated,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHourCycle(
|
||||
clockFormat: UserPreferences["clockFormat"],
|
||||
): "h12" | "h23" | undefined {
|
||||
if (clockFormat === "12h") return "h12";
|
||||
if (clockFormat === "24h") return "h23";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compareLanguages(a: string, b: string) {
|
||||
const baseA = a.split("-")[0];
|
||||
const baseB = b.split("-")[0];
|
||||
|
||||
return baseA.toUpperCase() === baseB.toUpperCase();
|
||||
}
|
||||
64
apps/web-react/app/hooks/swr.ts
Normal file
64
apps/web-react/app/hooks/swr.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import type { PatronsListLoaderData } from "~/features/front-page/routes/patrons-list";
|
||||
import { getWeaponUsage } from "~/features/sendouq/q-urls";
|
||||
import type { FriendsForAddingLoaderData } from "~/features/sendouq/routes/friends-for-adding";
|
||||
import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { GET_FRIENDS_FOR_ADDING_ROUTE, PATRONS_LIST_ROUTE } from "~/utils/urls";
|
||||
|
||||
// TODO: replace with useFetcher after proper errr handling is implemented https://github.com/remix-run/react-router/discussions/10013
|
||||
|
||||
const fetcher = (key: string) => async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
if (res.status !== 200) {
|
||||
logger.error(`swr error ${key}: status code ${res.status}`);
|
||||
throw new Error("fetching failed");
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export function useWeaponUsage(args: {
|
||||
userId: number;
|
||||
season: number;
|
||||
modeShort: ModeShort;
|
||||
stageId: StageId;
|
||||
}) {
|
||||
const { data, error } = useSWRImmutable<WeaponUsageLoaderData>(
|
||||
getWeaponUsage(args),
|
||||
fetcher("getWeaponUsage"),
|
||||
);
|
||||
|
||||
return {
|
||||
weaponUsage: data?.usage,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFriendsForAdding() {
|
||||
const { data, error } = useSWRImmutable<FriendsForAddingLoaderData>(
|
||||
GET_FRIENDS_FOR_ADDING_ROUTE,
|
||||
fetcher(GET_FRIENDS_FOR_ADDING_ROUTE),
|
||||
);
|
||||
|
||||
return {
|
||||
friends: data?.friendsForAdding.friends,
|
||||
teams: data?.friendsForAdding.teams,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePatrons() {
|
||||
const { data, error } = useSWRImmutable<PatronsListLoaderData>(
|
||||
PATRONS_LIST_ROUTE,
|
||||
fetcher(PATRONS_LIST_ROUTE),
|
||||
);
|
||||
|
||||
return {
|
||||
patrons: data?.patrons,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
}
|
||||
59
apps/web-react/app/hooks/useActionSubmit.ts
Normal file
59
apps/web-react/app/hooks/useActionSubmit.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { type FetcherWithComponents, useFetcher } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
type ActionsOf,
|
||||
type FieldsOf,
|
||||
serializeFieldValue,
|
||||
} from "~/utils/action-schemas";
|
||||
|
||||
interface UseActionSubmitOptions {
|
||||
/** Route to submit to. Defaults to the current route. */
|
||||
action?: string;
|
||||
/** Defaults to "application/x-www-form-urlencoded". */
|
||||
encType?: "application/x-www-form-urlencoded" | "application/json";
|
||||
/** Fetcher to submit with, e.g. to share submitting state. Defaults to own fetcher. */
|
||||
fetcher?: FetcherWithComponents<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic counterpart of `<ActionButton>`: submits an `_action` mutation
|
||||
* from an event handler, type checked against the route's zod action schema.
|
||||
*
|
||||
* @example
|
||||
* const { submit } = useActionSubmit(deleteFriendSchema);
|
||||
* submit("DELETE_FRIEND", { friendshipId });
|
||||
*/
|
||||
export function useActionSubmit<TSchema extends z.ZodTypeAny>(
|
||||
_schema: TSchema,
|
||||
opts?: UseActionSubmitOptions,
|
||||
) {
|
||||
const ownFetcher = useFetcher();
|
||||
const fetcher = opts?.fetcher ?? ownFetcher;
|
||||
|
||||
const submit = <const TAction extends ActionsOf<TSchema>>(
|
||||
action: TAction,
|
||||
// biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields"
|
||||
...rest: {} extends FieldsOf<TSchema, TAction>
|
||||
? [fields?: FieldsOf<TSchema, TAction>]
|
||||
: [fields: FieldsOf<TSchema, TAction>]
|
||||
) => {
|
||||
const fields = (rest[0] ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (opts?.encType === "application/json") {
|
||||
fetcher.submit(
|
||||
{ _action: action, ...fields } as Parameters<typeof fetcher.submit>[0],
|
||||
{ method: "post", action: opts?.action, encType: "application/json" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, string> = { _action: action };
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
payload[name] = serializeFieldValue(value);
|
||||
}
|
||||
fetcher.submit(payload, { method: "post", action: opts?.action });
|
||||
};
|
||||
|
||||
return { submit, fetcher, state: fetcher.state };
|
||||
}
|
||||
90
apps/web-react/app/hooks/useAutoRerender.ts
Normal file
90
apps/web-react/app/hooks/useAutoRerender.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface AutoRerenderOptions {
|
||||
alignTo: Date;
|
||||
}
|
||||
|
||||
const EVERY_TO_MS = {
|
||||
second: 1000,
|
||||
"ten seconds": 10_000,
|
||||
minute: 60_000,
|
||||
} as const;
|
||||
|
||||
interface TickStore {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getSnapshot: () => number;
|
||||
}
|
||||
|
||||
const tickStores = new Map<string, TickStore>();
|
||||
|
||||
function getTickStore(everyMs: number, alignToMs: number): TickStore {
|
||||
const key = `${everyMs}:${alignToMs}`;
|
||||
const existing = tickStores.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const scheduleNextTick = () => {
|
||||
const elapsed = Date.now() - alignToMs;
|
||||
const remainder = ((elapsed % everyMs) + everyMs) % everyMs;
|
||||
timeout = setTimeout(() => {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
scheduleNextTick();
|
||||
}, everyMs - remainder);
|
||||
};
|
||||
|
||||
const store: TickStore = {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) scheduleNextTick();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
clearTimeout(timeout);
|
||||
tickStores.delete(key);
|
||||
}
|
||||
};
|
||||
},
|
||||
getSnapshot: () => Math.floor((Date.now() - alignToMs) / everyMs),
|
||||
};
|
||||
tickStores.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the component to rerender periodically. Returns the `Date` of the
|
||||
* latest tick — callers should consume this value (e.g. pass it to date-fns)
|
||||
* so React Compiler can see the state is observable and won't memoize the
|
||||
* rerender away.
|
||||
*
|
||||
* Backed by a shared tick store: all components subscribed to the same
|
||||
* interval (and `alignTo` reference time) share a single timer. Ticks are
|
||||
* aligned via a self-rescheduling `setTimeout`, so with `every: "minute"` the
|
||||
* component rerenders at exactly `alignTo + N*60s` rather than at arbitrary
|
||||
* offsets from mount.
|
||||
*/
|
||||
export function useAutoRerender(): Date;
|
||||
export function useAutoRerender(every: "second" | "ten seconds"): Date;
|
||||
export function useAutoRerender(
|
||||
every: "minute",
|
||||
options: AutoRerenderOptions,
|
||||
): Date;
|
||||
export function useAutoRerender(
|
||||
every: "second" | "ten seconds" | "minute" = "second",
|
||||
options?: AutoRerenderOptions,
|
||||
): Date {
|
||||
const everyMs = EVERY_TO_MS[every];
|
||||
const alignToMs = options?.alignTo.getTime() ?? 0;
|
||||
|
||||
const store = getTickStore(everyMs, alignToMs);
|
||||
const tick = React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getSnapshot,
|
||||
);
|
||||
|
||||
return new Date(alignToMs + tick * everyMs);
|
||||
}
|
||||
38
apps/web-react/app/hooks/useBackgroundResource.ts
Normal file
38
apps/web-react/app/hooks/useBackgroundResource.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Keeps an app shell resource route's JSON fresh, outside the router. A
|
||||
* `useFetcher` load that is still in flight when a navigation starts is folded
|
||||
* into that navigation and has to settle before it completes (React Router
|
||||
* reruns cancelled fetcher loads without consulting `shouldRevalidate`), so a
|
||||
* refresh that only feeds the app shell would hold up every page change it
|
||||
* happens to overlap with.
|
||||
*/
|
||||
export function useBackgroundResource<T>(url: string) {
|
||||
const [data, setData] = React.useState<T>();
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const latestRequestRef = React.useRef(0);
|
||||
|
||||
// stable so effects that refresh after a mutation don't re-run every render
|
||||
const refresh = React.useCallback(async () => {
|
||||
const requestId = ++latestRequestRef.current;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return;
|
||||
|
||||
const json = (await response.json()) as T;
|
||||
// a newer refresh already started, its response is the fresher one
|
||||
if (requestId !== latestRequestRef.current) return;
|
||||
|
||||
setData(json);
|
||||
} catch {
|
||||
// a background refresh failing just leaves the last data in place
|
||||
} finally {
|
||||
if (requestId === latestRequestRef.current) setIsLoading(false);
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
return { data, isLoading, refresh };
|
||||
}
|
||||
39
apps/web-react/app/hooks/useCopyToClipboard.ts
Normal file
39
apps/web-react/app/hooks/useCopyToClipboard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
|
||||
const COPY_SUCCESS_DURATION_MS = 2000;
|
||||
|
||||
/**
|
||||
* Copies a string to the clipboard and exposes `copySuccess`, which stays
|
||||
* `true` for a short while after a successful copy so a confirmation (e.g. a
|
||||
* checkmark) can be flashed. `reset` clears `copySuccess` immediately.
|
||||
*/
|
||||
export function useCopyToClipboard(): {
|
||||
copyToClipboard: (value: string) => void;
|
||||
copySuccess: boolean;
|
||||
reset: () => void;
|
||||
} {
|
||||
const [copySuccess, setCopySuccess] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copySuccess) return;
|
||||
|
||||
const timeout = setTimeout(
|
||||
() => setCopySuccess(false),
|
||||
COPY_SUCCESS_DURATION_MS,
|
||||
);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [copySuccess]);
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
if (!value) return;
|
||||
|
||||
navigator.clipboard.writeText(value).then(
|
||||
() => setCopySuccess(true),
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
const reset = () => setCopySuccess(false);
|
||||
|
||||
return { copyToClipboard, copySuccess, reset };
|
||||
}
|
||||
23
apps/web-react/app/hooks/useDebounce.ts
Normal file
23
apps/web-react/app/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Runs `fn` once `ms` has elapsed without any value in `deps` changing. The
|
||||
* timer is (re)started on mount and whenever `ms` or a value in `deps` changes.
|
||||
*
|
||||
* Uses the latest-ref pattern instead of `useEffectEvent`: effect events don't
|
||||
* update past the first render inside `React.memo`/`React.forwardRef` wrapped
|
||||
* components (React 19.2), which would silently break callers.
|
||||
*/
|
||||
export function useDebounce(
|
||||
fn: () => void,
|
||||
ms = 0,
|
||||
deps: React.DependencyList = [],
|
||||
) {
|
||||
const callback = React.useRef(fn);
|
||||
callback.current = fn;
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => callback.current(), ms);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [ms, ...deps]);
|
||||
}
|
||||
32
apps/web-react/app/hooks/useHydrated.ts
Normal file
32
apps/web-react/app/hooks/useHydrated.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
|
||||
// credits: https://github.com/sergiodxa/remix-utils/blob/main/src/react/use-hydrated.ts
|
||||
|
||||
function subscribe() {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a boolean indicating if the JS has been hydrated already.
|
||||
* When doing Server-Side Rendering, the result will always be false.
|
||||
* When doing Client-Side Rendering, the result will always be false on the
|
||||
* first render and true from then on. Even if a new component renders it will
|
||||
* always start with true.
|
||||
*
|
||||
* Example: Disable a button that needs JS to work.
|
||||
* ```tsx
|
||||
* let hydrated = useHydrated();
|
||||
* return (
|
||||
* <button type="button" disabled={!hydrated} onClick={doSomethingCustom}>
|
||||
* Click me
|
||||
* </button>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useHydrated() {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
4
apps/web-react/app/hooks/useIsomorphicLayoutEffect.ts
Normal file
4
apps/web-react/app/hooks/useIsomorphicLayoutEffect.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useEffect, useLayoutEffect } from "react";
|
||||
|
||||
export const useIsomorphicLayoutEffect =
|
||||
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
||||
48
apps/web-react/app/hooks/useMainContentWidth.ts
Normal file
48
apps/web-react/app/hooks/useMainContentWidth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { useWindowSize } from "./useWindowSize";
|
||||
|
||||
const MOBILE_BREAKPOINT = 600;
|
||||
const DESKTOP_BREAKPOINT = 1000;
|
||||
|
||||
type LayoutSize = "mobile" | "tablet" | "desktop";
|
||||
|
||||
export function useLayoutSize(): LayoutSize {
|
||||
const { width } = useWindowSize();
|
||||
|
||||
if (width === 0) return "desktop";
|
||||
if (width < MOBILE_BREAKPOINT) return "mobile";
|
||||
if (width < DESKTOP_BREAKPOINT) return "tablet";
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let observer: ResizeObserver | null = null;
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!observer) {
|
||||
observer = new ResizeObserver(() => {
|
||||
for (const notify of listeners) {
|
||||
notify();
|
||||
}
|
||||
});
|
||||
const main = document.querySelector("main");
|
||||
if (main) observer.observe(main);
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return document.querySelector("main")?.clientWidth ?? 0;
|
||||
}
|
||||
|
||||
export function useMainContentWidth() {
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, () => 0);
|
||||
}
|
||||
64
apps/web-react/app/hooks/usePagination.ts
Normal file
64
apps/web-react/app/hooks/usePagination.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
|
||||
export function usePagination<T>({
|
||||
items,
|
||||
pageSize,
|
||||
scrollToTop = true,
|
||||
}: {
|
||||
items: T[];
|
||||
pageSize: number;
|
||||
scrollToTop?: boolean;
|
||||
}) {
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const pagesCount = Math.ceil(items.length / pageSize);
|
||||
|
||||
const itemsToDisplay = React.useMemo(
|
||||
() => items.slice((currentPage - 1) * pageSize, currentPage * pageSize),
|
||||
[currentPage, items, pageSize],
|
||||
);
|
||||
|
||||
const nextPage = React.useCallback(() => {
|
||||
if (currentPage < pagesCount) {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
if (scrollToTop) window.scrollTo(0, 0);
|
||||
}
|
||||
}, [currentPage, pagesCount, scrollToTop]);
|
||||
|
||||
const previousPage = React.useCallback(() => {
|
||||
if (currentPage > 1) {
|
||||
setCurrentPage((prev) => prev - 1);
|
||||
if (scrollToTop) window.scrollTo(0, 0);
|
||||
}
|
||||
}, [currentPage, scrollToTop]);
|
||||
|
||||
const setPage = React.useCallback(
|
||||
(page: number) => {
|
||||
if (page > 0 && page <= pagesCount) {
|
||||
setCurrentPage(page);
|
||||
if (scrollToTop) window.scrollTo(0, 0);
|
||||
}
|
||||
},
|
||||
[pagesCount, scrollToTop],
|
||||
);
|
||||
|
||||
const thereIsNextPage = currentPage < pagesCount;
|
||||
const thereIsPreviousPage = currentPage > 1;
|
||||
|
||||
// if the list changes from externally it might be that we are on a page that doesn't exist anymore
|
||||
// setting state inside render looks weird but should be ok
|
||||
if (itemsToDisplay.length === 0 && currentPage > 1) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
|
||||
return {
|
||||
pagesCount,
|
||||
currentPage,
|
||||
itemsToDisplay,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPage,
|
||||
thereIsNextPage,
|
||||
thereIsPreviousPage,
|
||||
everythingVisible: items.length === itemsToDisplay.length,
|
||||
};
|
||||
}
|
||||
22
apps/web-react/app/hooks/usePrefersReducedMotion.ts
Normal file
22
apps/web-react/app/hooks/usePrefersReducedMotion.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
const QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
const mediaQueryList = window.matchMedia(QUERY);
|
||||
mediaQueryList.addEventListener("change", callback);
|
||||
return () => mediaQueryList.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a boolean indicating whether the user has requested reduced motion
|
||||
* via the `prefers-reduced-motion` media query. Always returns `false` during
|
||||
* server-side rendering and on the first client render.
|
||||
*/
|
||||
export function usePrefersReducedMotion() {
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(QUERY).matches,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
20
apps/web-react/app/hooks/useRecentlyReportedWeapons.test.ts
Normal file
20
apps/web-react/app/hooks/useRecentlyReportedWeapons.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, test } from "vitest";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/persisted-state/persisted-state-test-utils";
|
||||
import { recentlyReportedWeaponsPersisted } from "./useRecentlyReportedWeapons";
|
||||
|
||||
describe("recentlyReportedWeaponsPersisted", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(recentlyReportedWeaponsPersisted, [[], [0, 10, 8000]]);
|
||||
});
|
||||
|
||||
test("malformed values decode to the default", () => {
|
||||
assertDecodesToDefault(recentlyReportedWeaponsPersisted, [
|
||||
"not json",
|
||||
"[99999]",
|
||||
'["0"]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
40
apps/web-react/app/hooks/useRecentlyReportedWeapons.ts
Normal file
40
apps/web-react/app/hooks/useRecentlyReportedWeapons.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { usePersistedState } from "~/modules/persisted-state/hooks";
|
||||
import * as PersistedState from "~/modules/persisted-state/persisted-state";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
|
||||
const MAX_REPORTED_WEAPONS = 7;
|
||||
|
||||
export const recentlyReportedWeaponsPersisted = PersistedState.define({
|
||||
key: "sq__recently-reported-weapons",
|
||||
storage: "local",
|
||||
schema: z.array(numericEnum(mainWeaponIds)),
|
||||
default: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* This hook provides access to the list of recently reported weapons,
|
||||
* which is persisted in local storage, and a function to add a new weapon
|
||||
* to the list.
|
||||
*
|
||||
* If a weapon is added that already exists in the list, it will be moved to the front of the list.
|
||||
* If the list exceeds the maximum number of reported weapons, the oldest weapon will be removed.
|
||||
*/
|
||||
export function useRecentlyReportedWeapons() {
|
||||
const [recentlyReportedWeapons, setRecentlyReportedWeapons] =
|
||||
usePersistedState(recentlyReportedWeaponsPersisted);
|
||||
|
||||
const addRecentlyReportedWeapon = (weapon: MainWeaponId) => {
|
||||
setRecentlyReportedWeapons((previous) =>
|
||||
PersistedState.prependToRecentList(
|
||||
previous,
|
||||
weapon,
|
||||
MAX_REPORTED_WEAPONS,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return { recentlyReportedWeapons, addRecentlyReportedWeapon };
|
||||
}
|
||||
33
apps/web-react/app/hooks/useReloadOnNewDeploy.ts
Normal file
33
apps/web-react/app/hooks/useReloadOnNewDeploy.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import * as PersistedState from "~/modules/persisted-state/persisted-state";
|
||||
import { GIT_COMMIT } from "~/utils/git-commit";
|
||||
|
||||
const reloadedForCommitPersisted = PersistedState.define({
|
||||
key: "reloadedForCommit",
|
||||
storage: "session",
|
||||
schema: z.string(),
|
||||
default: "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Reloads the page when the server starts reporting a different build than the
|
||||
* one this client's JavaScript was loaded from. Pages that stay open for hours
|
||||
* while revalidating (SendouQ looking above all) otherwise keep running old
|
||||
* code against new loader data, breaking whenever a deploy changes the shape of
|
||||
* that data.
|
||||
*/
|
||||
export function useReloadOnNewDeploy(serverCommit: string) {
|
||||
React.useEffect(() => {
|
||||
if (!GIT_COMMIT || !serverCommit || serverCommit === GIT_COMMIT) return;
|
||||
|
||||
// the reload is expected to serve the new bundle, but should it not, the
|
||||
// same build is never retried so the page can't end up in a reload loop
|
||||
if (PersistedState.read(reloadedForCommitPersisted) === serverCommit) {
|
||||
return;
|
||||
}
|
||||
|
||||
PersistedState.write(reloadedForCommitPersisted, serverCommit);
|
||||
window.location.reload();
|
||||
}, [serverCommit]);
|
||||
}
|
||||
47
apps/web-react/app/hooks/useSearchParamPagination.ts
Normal file
47
apps/web-react/app/hooks/useSearchParamPagination.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import type {
|
||||
ParamDef,
|
||||
SearchParamsDefinition,
|
||||
SearchParamsValues,
|
||||
} from "~/modules/search-params/search-params";
|
||||
|
||||
type PaginatedShape = { page: ParamDef<number> } & Record<
|
||||
string,
|
||||
ParamDef<any>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Pagination state for pages where the current page lives in the `page` search
|
||||
* param of the given search params definition and the loader takes care of
|
||||
* slicing the results.
|
||||
*
|
||||
* Returns props that can be spread to the `<Pagination />` component.
|
||||
*
|
||||
* For paginating a list that is fully available on the client, see `usePagination`.
|
||||
*/
|
||||
export function useSearchParamPagination<Shape extends PaginatedShape>({
|
||||
definition,
|
||||
currentPage,
|
||||
pagesCount,
|
||||
}: {
|
||||
definition: SearchParamsDefinition<Shape>;
|
||||
currentPage: number;
|
||||
pagesCount: number;
|
||||
}) {
|
||||
const [, setParams] = useSearchParamsTyped(definition);
|
||||
|
||||
const setPage = (page: number) => {
|
||||
setParams({ page } as Partial<SearchParamsValues<Shape>>, {
|
||||
replace: false,
|
||||
preventScrollReset: false,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pagesCount,
|
||||
setPage,
|
||||
nextPage: () => setPage(currentPage + 1),
|
||||
previousPage: () => setPage(currentPage - 1),
|
||||
};
|
||||
}
|
||||
16
apps/web-react/app/hooks/useSpoilerFree.test.ts
Normal file
16
apps/web-react/app/hooks/useSpoilerFree.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, test } from "vitest";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/persisted-state/persisted-state-test-utils";
|
||||
import { revealedTournamentsPersisted } from "./useSpoilerFree";
|
||||
|
||||
describe("revealedTournamentsPersisted", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(revealedTournamentsPersisted, [[], [1, 2, 3]]);
|
||||
});
|
||||
|
||||
test("malformed values decode to the default", () => {
|
||||
assertDecodesToDefault(revealedTournamentsPersisted, ["not json", "1"]);
|
||||
});
|
||||
});
|
||||
33
apps/web-react/app/hooks/useSpoilerFree.ts
Normal file
33
apps/web-react/app/hooks/useSpoilerFree.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { usePersistedState } from "~/modules/persisted-state/hooks";
|
||||
import * as PersistedState from "~/modules/persisted-state/persisted-state";
|
||||
|
||||
export const revealedTournamentsPersisted = PersistedState.define({
|
||||
key: "spoilerFreeRevealed",
|
||||
storage: "session",
|
||||
schema: z.array(z.number()),
|
||||
default: [],
|
||||
});
|
||||
|
||||
export function useSpoilerFree() {
|
||||
const user = useUser();
|
||||
const [revealedIds, setRevealedIds] = usePersistedState(
|
||||
revealedTournamentsPersisted,
|
||||
);
|
||||
|
||||
const isEnabled = Boolean(user?.preferences.spoilerFreeMode);
|
||||
|
||||
const isCensored = (tournamentId: number) =>
|
||||
isEnabled && !revealedIds.includes(tournamentId);
|
||||
|
||||
const reveal = (tournamentId: number) => {
|
||||
setRevealedIds((previous) => [...previous, tournamentId]);
|
||||
};
|
||||
|
||||
const hide = (tournamentId: number) => {
|
||||
setRevealedIds((previous) => previous.filter((id) => id !== tournamentId));
|
||||
};
|
||||
|
||||
return { isEnabled, isCensored, reveal, hide };
|
||||
}
|
||||
43
apps/web-react/app/hooks/useThemeColors.ts
Normal file
43
apps/web-react/app/hooks/useThemeColors.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
const observer = new MutationObserver(listener);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return document.documentElement.className;
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves CSS custom properties to their computed values (e.g. for canvas
|
||||
* rendering which can't consume CSS variables directly), re-resolving when the
|
||||
* theme class on the root element changes. All values are empty strings during
|
||||
* server-side rendering and the initial hydration render.
|
||||
*/
|
||||
export function useThemeColors<K extends string>(
|
||||
cssVariables: Record<K, string>,
|
||||
): Record<K, string> {
|
||||
const themeClass = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getServerSnapshot,
|
||||
);
|
||||
|
||||
const style =
|
||||
themeClass === null ? null : getComputedStyle(document.documentElement);
|
||||
|
||||
const resolved = {} as Record<K, string>;
|
||||
for (const [key, cssVariable] of Object.entries<string>(cssVariables)) {
|
||||
resolved[key as K] = style?.getPropertyValue(cssVariable).trim() ?? "";
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
20
apps/web-react/app/hooks/useUnseenFriendRequests.test.ts
Normal file
20
apps/web-react/app/hooks/useUnseenFriendRequests.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, test } from "vitest";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/persisted-state/persisted-state-test-utils";
|
||||
import { seenFriendRequestsPersisted } from "./useUnseenFriendRequests";
|
||||
|
||||
describe("seenFriendRequestsPersisted", () => {
|
||||
test("round-trips", () => {
|
||||
assertRoundTrips(seenFriendRequestsPersisted, [[], [3, 2, 1]]);
|
||||
});
|
||||
|
||||
test("malformed values decode to the default", () => {
|
||||
assertDecodesToDefault(seenFriendRequestsPersisted, [
|
||||
"not json",
|
||||
'{"a":1}',
|
||||
'["a"]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
48
apps/web-react/app/hooks/useUnseenFriendRequests.ts
Normal file
48
apps/web-react/app/hooks/useUnseenFriendRequests.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as R from "remeda";
|
||||
import { z } from "zod";
|
||||
import { usePersistedState } from "~/modules/persisted-state/hooks";
|
||||
import * as PersistedState from "~/modules/persisted-state/persisted-state";
|
||||
|
||||
const MAX_STORED_IDS = 200;
|
||||
|
||||
export const seenFriendRequestsPersisted = PersistedState.define({
|
||||
key: "seen-friend-requests",
|
||||
storage: "local",
|
||||
schema: z.array(z.number()),
|
||||
default: [],
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the count of incoming friend requests that the user has not yet seen.
|
||||
*
|
||||
* "Seen" requests are tracked per-device in local storage (the dedicated /friends
|
||||
* page remains the authoritative view). The count updates when requests are marked
|
||||
* as seen via {@link markFriendRequestsSeen} in the same tab, or when local storage
|
||||
* changes in another tab.
|
||||
*/
|
||||
export function useUnseenFriendRequests(incomingRequestIds: number[]): number {
|
||||
const [seenIdsList] = usePersistedState(seenFriendRequestsPersisted);
|
||||
|
||||
const seenIds = new Set(seenIdsList);
|
||||
|
||||
return incomingRequestIds.filter((id) => !seenIds.has(id)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the given incoming friend request ids as seen, clearing the unseen badge.
|
||||
*
|
||||
* Ids are merged into the existing seen set rather than replacing it, so that
|
||||
* acting on a request (e.g. declining it) doesn't make an already-seen request
|
||||
* count as unseen again while other consumers still have a stale list. Only the
|
||||
* most recent ids are kept to keep the stored set from growing without bound.
|
||||
*/
|
||||
export function markFriendRequestsSeen(ids: number[]) {
|
||||
const merged = R.unique([
|
||||
...PersistedState.read(seenFriendRequestsPersisted),
|
||||
...ids,
|
||||
])
|
||||
.sort((a, b) => b - a)
|
||||
.slice(0, MAX_STORED_IDS);
|
||||
|
||||
PersistedState.write(seenFriendRequestsPersisted, merged);
|
||||
}
|
||||
35
apps/web-react/app/hooks/useVisualViewportHeight.ts
Normal file
35
apps/web-react/app/hooks/useVisualViewportHeight.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
|
||||
|
||||
const CSS_VARIABLE = "--visual-viewport-height";
|
||||
|
||||
/**
|
||||
* Keeps the `--visual-viewport-height` CSS variable in sync with the visual
|
||||
* viewport height on the document root. CSS has no native way to read the
|
||||
* visual (as opposed to layout) viewport, so elements that must stay above the
|
||||
* mobile on-screen keyboard rely on this variable to clamp their height as the
|
||||
* keyboard opens and closes.
|
||||
*/
|
||||
export function useVisualViewportHeight() {
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return;
|
||||
|
||||
const update = () => {
|
||||
document.documentElement.style.setProperty(
|
||||
CSS_VARIABLE,
|
||||
`${viewport.height}px`,
|
||||
);
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
viewport.addEventListener("resize", update);
|
||||
viewport.addEventListener("scroll", update);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", update);
|
||||
viewport.removeEventListener("scroll", update);
|
||||
document.documentElement.style.removeProperty(CSS_VARIABLE);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
31
apps/web-react/app/hooks/useWindowSize.ts
Normal file
31
apps/web-react/app/hooks/useWindowSize.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current window dimensions, re-rendering on resize. Both
|
||||
* dimensions are `0` during server-side rendering and the initial hydration
|
||||
* render.
|
||||
*/
|
||||
export function useWindowSize(): WindowSize {
|
||||
const width = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.innerWidth,
|
||||
() => 0,
|
||||
);
|
||||
const height = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.innerHeight,
|
||||
() => 0,
|
||||
);
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
Reference in New Issue
Block a user