sendou.ink/app/features/lfg/routes/lfg.tsx
Kalle 77978c450f
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
New user page (#2812)
Co-authored-by: hfcRed <hfcred@gmx.net>
2026-02-16 19:26:57 +02:00

163 lines
4.9 KiB
TypeScript

import clsx from "clsx";
import { add, sub } from "date-fns";
import React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { AddNewButton } from "~/components/AddNewButton";
import { Alert } from "~/components/Alert";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useSearchParamStateEncoder } from "~/hooks/useSearchParamState";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import { LFG_PAGE, lfgNewPostPage, navIconUrl } from "~/utils/urls";
import { action } from "../actions/lfg.server";
import { LFGAddFilterButton } from "../components/LFGAddFilterButton";
import { LFGFilters } from "../components/LFGFilters";
import { LFGPost } from "../components/LFGPost";
import { filterPosts } from "../core/filtering";
import { LFG } from "../lfg-constants";
import {
filterToSmallStr,
type LFGFilter,
smallStrToFilter,
} from "../lfg-types";
import { loader } from "../loaders/lfg.server";
import styles from "./lfg.module.css";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: ["lfg"],
breadcrumb: () => ({
imgPath: navIconUrl("lfg"),
href: LFG_PAGE,
type: "IMAGE",
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "LFG",
ogTitle: "Splatoon LFG (looking for players, teams & coaches)",
description:
"Find people to play Splatoon with. Create a post or browse existing ones. For looking players, teams, scrim partners and coaches alike.",
location: args.location,
});
};
export type LFGLoaderData = SerializeFrom<typeof loader>;
export type LFGLoaderPost = Unpacked<LFGLoaderData["posts"]>;
export type TiersMap = ReturnType<typeof unserializeTiers>;
const unserializeTiers = (data: SerializeFrom<typeof loader>) =>
new Map(data.tiersMap);
function decodeURLQuery(queryString: string): LFGFilter[] {
if (queryString === "") {
return [];
}
return queryString
.split("-")
.map(smallStrToFilter)
.filter((x) => x !== null);
}
function encodeURLQuery(filters: LFGFilter[]): string {
return filters.map(filterToSmallStr).join("-");
}
export default function LFGPage() {
const { t } = useTranslation(["common", "lfg"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const [filterFromSearch, setTilterFromSearch] = useSearchParamStateEncoder({
defaultValue: [],
name: "q",
revive: decodeURLQuery,
encode: encodeURLQuery,
});
const [filters, _setFilters] = React.useState<LFGFilter[]>(filterFromSearch);
const setFilters = (x: LFGFilter[]) => {
setTilterFromSearch(x);
_setFilters(x);
};
const tiersMap = React.useMemo(() => unserializeTiers(data), [data]);
const filteredPosts = filterPosts(data.posts, filters, tiersMap);
const showExpiryAlert = (post: Unpacked<LFGLoaderData["posts"]>) => {
if (post.author.id !== user?.id) return false;
const expiryDate = add(databaseTimestampToDate(post.updatedAt), {
days: LFG.POST_FRESHNESS_DAYS,
});
const expiryCloseDate = sub(expiryDate, { days: 7 });
if (new Date() < expiryCloseDate) return false;
return true;
};
return (
<Main className="stack xl">
<div className="stack sm horizontal justify-end">
<LFGAddFilterButton
addFilter={(newFilter) => setFilters([...filters, newFilter])}
filters={filters}
/>
<AddNewButton navIcon="lfg" to={lfgNewPostPage()} />
</div>
<LFGFilters
filters={filters}
changeFilter={(newFilter) =>
setFilters(
filters.map((filter) =>
filter._tag === newFilter._tag ? newFilter : filter,
),
)
}
removeFilterByTag={(tag) =>
setFilters(filters.filter((filter) => filter._tag !== tag))
}
/>
{filteredPosts.map((post) => (
<div
key={post.id}
id={String(post.id)}
className={clsx("stack sm", styles.post)}
>
{showExpiryAlert(post) ? <PostExpiryAlert postId={post.id} /> : null}
<LFGPost post={post} tiersMap={tiersMap} />
</div>
))}
{filteredPosts.length === 0 ? (
<div className="text-lighter text-lg font-semi-bold text-center mt-6">
{t("lfg:noPosts")}
</div>
) : null}
</Main>
);
}
function PostExpiryAlert({ postId }: { postId: number }) {
const { t } = useTranslation(["common", "lfg"]);
const fetcher = useFetcher();
return (
<Alert variation="WARNING">
<fetcher.Form method="post" className="stack md horizontal items-center">
<input type="hidden" name="id" value={postId} />
{t("lfg:expiring")}{" "}
<SubmitButton _action="BUMP_POST" variant="outlined" size="small">
{t("common:actions.clickHere")}
</SubmitButton>
</fetcher.Form>
</Alert>
);
}