Files
sendou.ink/app/utils/remix.server.ts
Kalle e3bfd23dc2
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Set up prefetch intent
2026-09-09 20:21:49 +03:00

251 lines
6.8 KiB
TypeScript

import type { Namespace, TFunction } from "i18next";
import type { Params, UIMatch } from "react-router";
import { redirect } from "react-router";
import * as v from "valibot";
import type { navItems } from "~/components/layout/nav-items";
import { ServerConfig } from "~/config.server";
import type { Ok, Result } from "~/utils/result";
import type { AnySchema, AnySyncSchema } from "~/utils/schema";
import { logger } from "./logger";
import { currentRequestPathname } from "./request-context.server";
export function notFoundIfNullish<T>(value: T | null | undefined): T {
if (value === null || value === undefined) {
throw new Response(null, { status: 404 });
}
return value;
}
export function unauthorizedIfFalsy<T>(value: T | null | undefined): T {
if (!value) throw new Response(null, { status: 401 });
return value;
}
/** Throws a HTTP 403 (Forbidden) response, ending execution of the loader/action early */
export function forbidden() {
throw new Response(null, { status: 403 });
}
export function badRequestIfFalsy<T>(value: T | null | undefined): T {
if (!value) {
throw new Response(null, { status: 400 });
}
return value;
}
/**
* Pagination state for a loader driven by the `page` search param. `pagesCount` is at least 1;
* a `page` past it throws a redirect to the last page (other search params preserved).
*/
export function paginate({
url,
page,
pageSize,
totalCount,
}: {
url: URL;
page: number;
pageSize: number;
totalCount: number;
}): { currentPage: number; pagesCount: number } {
const pagesCount = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > pagesCount) {
const searchParams = new URLSearchParams(url.searchParams);
searchParams.set("page", String(pagesCount));
throw redirect(`${url.pathname}?${searchParams.toString()}`);
}
return { currentPage: page, pagesCount };
}
/** Parses request payload with the schema, error toast redirect on failure. With SendouForm use `parseFormData` from `~/form/parse.server` instead. */
export async function parseRequestPayload<T extends AnySchema>({
request,
schema,
}: {
request: Request;
schema: T;
}): Promise<v.InferOutput<T>> {
const formDataObj =
request.headers.get("Content-Type") === "application/json"
? await request.json()
: formDataToObject(await request.formData());
try {
return await v.parseAsync(schema, formDataObj);
} catch (e) {
logger.error("Error parsing request payload", e);
throw errorToastRedirect("Validation failed");
}
}
/** Parse params with the given schema. Throws HTTP 404 response if fails. */
export function parseParams<T extends AnySyncSchema>({
params,
schema,
}: {
params: Params<string>;
schema: T;
}): v.InferOutput<T> {
const parsed = v.safeParse(schema, params);
if (!parsed.success) {
throw new Response(null, { status: 404 });
}
return parsed.output;
}
/** Parse JSON body with the given schema. Throws HTTP 400 response if fails. */
export async function parseBody<T extends AnySyncSchema>({
request,
schema,
}: {
request: Request;
schema: T;
}): Promise<v.InferOutput<T>> {
const parsed = v.safeParse(schema, await request.json());
if (!parsed.success) {
throw new Response(null, { status: 400 });
}
return parsed.output;
}
export function formDataToObject(formData: FormData) {
const result: Record<string, string | string[]> = {};
for (const [key, value] of formData.entries()) {
const newValue = String(value);
const existingValue = result[key];
if (Array.isArray(existingValue)) {
existingValue.push(newValue);
} else if (typeof existingValue === "string") {
result[key] = [existingValue, newValue];
} else {
result[key] = newValue;
}
}
return result;
}
const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";
/** Some endpoints can only be accessed with an auth token. Used by Lohi bot and cron jobs. */
/** A `returnTo` form value narrowed to a same-site path, so that redirecting to it can not leave the site. */
export function safeReturnTo(value: FormDataEntryValue | null) {
if (typeof value !== "string") return null;
// a second slash or backslash makes it a protocol-relative URL to another host
if (!/^\/(?![\\/])/.test(value)) return null;
return value;
}
export function canAccessLohiEndpoint(request: Request) {
return request.headers.get(LOHI_TOKEN_HEADER_NAME) === ServerConfig.lohiToken;
}
export function errorToastRedirect(message: string) {
return redirect(
urlWithToastParam(currentRequestPathname() ?? "", "__error", message),
);
}
/** Asserts condition is truthy. Throws a redirect triggering an error toast with given message otherwise. */
export function errorToastIfFalsy(
condition: any,
message: string,
): asserts condition {
if (condition) return;
throw errorToastRedirect(message);
}
/** Asserts `Result` is `Ok`; on `Err` throws a redirect showing the error as a toast. */
export function errorToastIfErr<T, E extends string>(
value: Result<T, E>,
): asserts value is Ok<T> {
if (!value.ok) {
throw errorToastRedirect(value.error);
}
}
/** Throws a redirect triggering an error toast with given message. */
export function errorToast(message: string) {
throw errorToastRedirect(message);
}
export function successToast(message: string) {
return redirect(
urlWithToastParam(currentRequestPathname() ?? "", "__success", message),
);
}
export function successToastWithRedirect({
message,
url,
}: {
message: string;
url: string;
}) {
return redirect(urlWithToastParam(url, "__success", message));
}
function urlWithToastParam(
url: string,
param: "__error" | "__success",
message: string,
) {
const [pathnameAndSearch, hash] = splitOnce(url, "#");
const [pathname, search] = splitOnce(pathnameAndSearch, "?");
const searchParams = new URLSearchParams(search);
searchParams.set(param, message);
return `${pathname}?${searchParams}${hash ? `#${hash}` : ""}`;
}
function splitOnce(value: string, separator: string) {
const index = value.indexOf(separator);
return index === -1
? ([value, undefined] as const)
: ([value.slice(0, index), value.slice(index + 1)] as const);
}
export type Breadcrumb =
| {
imgPath: string;
type: "IMAGE";
href: string;
text?: string;
/** Seed for the identicon shown if `imgPath` fails to load. */
identiconInput?: string;
}
| { text: string; type: "TEXT"; href: string };
/** Route `handle` shape; read for all active routes via `useMatches()`. */
export type SendouRouteHandle = {
/** i18n namespaces loaded for this route */
i18n?: Namespace;
breadcrumb?: (args: {
match: UIMatch;
t: TFunction<"common", undefined>;
}) => Breadcrumb | Array<Breadcrumb> | undefined;
/** navItem active on this route, see nav-items.ts */
navItemName?: (typeof navItems)[number]["name"];
/**
* Parent layout's `<Main>` fills the whole content area (content stays centered at normal
* width) so a descendant like the bracket can grow wider than the page.
*/
mainBreakout?: boolean;
};