sendou.ink/app/features/lfg/routes/lfg.new.tsx
Kalle 4beb2bdfdd
LFG (#1732)
* Initial

* Can post new

* Load team

* seed + loader posts

* LFGPost render initial

* More UI work

* Tiers

* sticky left

* Mobile

* new.tsx work

* TeamLFGPost component initial

* Full team member list

* Add TODO

* Delete post action

* Edit post etc.

* Delete team posts when team disbands

* Prevent adding same post type twice in UI

* Post expiry logic

* Fix layout shift

* Filters initial

* Progress

* Weapon filtered implemented

* Weapon alt kits in filtering

* + visibility

* i18n

* E2E test

* Team = null
2024-05-19 13:43:59 +03:00

272 lines
7.4 KiB
TypeScript

import { Link, useFetcher, useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { TEAM_POST_TYPES, LFG, TIMEZONES } from "../lfg-constants";
import * as React from "react";
import { Label } from "~/components/Label";
import type { SendouRouteHandle } from "~/utils/remix";
import {
LFG_PAGE,
SENDOUQ_SETTINGS_PAGE,
navIconUrl,
userEditProfilePage,
} from "~/utils/urls";
import { FormMessage } from "~/components/FormMessage";
import { WeaponImage } from "~/components/Image";
import { useUser } from "~/features/auth/core/user";
import type { Tables } from "~/db/tables";
import { LinkButton } from "~/components/Button";
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
import { loader } from "../loaders/lfg.new.server";
import { action } from "../actions/lfg.new.server";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: ["lfg"],
breadcrumb: () => ({
imgPath: navIconUrl("lfg"),
href: LFG_PAGE,
type: "IMAGE",
}),
};
export default function LFGNewPostPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const { t } = useTranslation(["common", "lfg"]);
const availableTypes = useAvailablePostTypes();
const [type, setType] = React.useState(data.postToEdit?.type ?? LFG.types[0]);
if (availableTypes.length === 0) {
return (
<Main halfWidth className="stack items-center">
<h2 className="text-lg mb-4">{t("lfg:new.noMorePosts")}</h2>
<LinkButton to={LFG_PAGE} icon={<ArrowLeftIcon />}>
{t("common:actions.goBack")}
</LinkButton>
</Main>
);
}
return (
<Main halfWidth>
<h2 className="text-lg mb-4">
{data.postToEdit ? "Editing LFG post" : "New LFG post"}
</h2>
<fetcher.Form className="stack md items-start" method="post">
{data.postToEdit ? (
<input type="hidden" name="postId" value={data.postToEdit.id} />
) : null}
<TypeSelect
type={type}
setType={setType}
availableTypes={availableTypes}
/>
<TimezoneSelect />
<Textarea />
{user?.plusTier && type !== "COACH_FOR_TEAM" ? (
<PlusVisibilitySelect />
) : null}
<Languages />
{type !== "COACH_FOR_TEAM" && <WeaponPool />}
<SubmitButton state={fetcher.state}>
{t("common:actions.submit")}
</SubmitButton>
</fetcher.Form>
</Main>
);
}
const useAvailablePostTypes = () => {
const data = useLoaderData<typeof loader>();
return (
LFG.types
// can't look for a team, if not in one
.filter((type) => data.team || !TEAM_POST_TYPES.includes(type))
// can't post two posts of same type
.filter(
(type) =>
!data.userPostTypes.includes(type) || data.postToEdit?.type === type,
)
);
};
function TypeSelect({
type,
setType,
availableTypes,
}: {
type: Tables["LFGPost"]["type"];
setType: (type: Tables["LFGPost"]["type"]) => void;
availableTypes: Tables["LFGPost"]["type"][];
}) {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("lfg:new.type.header")}</Label>
{data.postToEdit ? (
<input type="hidden" name="type" value={type} />
) : null}
<select
name="type"
value={type}
onChange={(e) => setType(e.target.value as Tables["LFGPost"]["type"])}
disabled={Boolean(data.postToEdit)}
>
{availableTypes.map((type) => (
<option key={type} value={type}>
{t(`lfg:types.${type}`)}{" "}
{data.team && TEAM_POST_TYPES.includes(type)
? `(${data.team.name})`
: ""}
</option>
))}
</select>
</div>
);
}
function TimezoneSelect() {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
const [selected, setSelected] = React.useState(
data.postToEdit?.timezone ?? TIMEZONES[0],
);
React.useEffect(() => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!TIMEZONES.includes(timezone)) return;
setSelected(timezone);
}, []);
return (
<div>
<Label>{t("lfg:new.timezone.header")}</Label>
<select
name="timezone"
onChange={(e) => setSelected(e.target.value)}
value={selected}
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
</div>
);
}
function Textarea() {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.postToEdit?.text ?? "");
return (
<div>
<Label
htmlFor="postText"
valueLimits={{ current: value.length, max: LFG.MAX_TEXT_LENGTH }}
>
{t("lfg:new.text.header")}
</Label>
<textarea
id="postText"
name="postText"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={LFG.MAX_TEXT_LENGTH}
required
/>
</div>
);
}
function PlusVisibilitySelect() {
const { t } = useTranslation(["lfg"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const [selected, setSelected] = React.useState<number | "">(
data.postToEdit?.plusTierVisibility ?? "",
);
const options = [1, 2, 3].filter(
(tier) => user && user.plusTier && tier >= user?.plusTier,
);
return (
<div>
<Label>{t("lfg:new.visibility.header")}</Label>
<select
name="plusTierVisibility"
onChange={(e) =>
setSelected(e.target.value === "" ? "" : Number(e.target.value))
}
value={selected}
>
{options.map((tier) => (
<option key={tier} value={tier}>
+{tier} {tier > 1 ? t("lfg:filters.orAbove") : ""}
</option>
))}
<option value="">{t("lfg:new.visibility.everyone")}</option>
</select>
</div>
);
}
function Languages() {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("lfg:new.languages.header")}</Label>
<div className="stack horizontal sm">
{data.languages?.join(" / ").toUpperCase()}
</div>
<FormMessage type="info">
{t("lfg:new.editOn")}{" "}
<Link to={SENDOUQ_SETTINGS_PAGE} target="_blank" rel="noreferrer">
{t("lfg:new.languages.sqSettingsPage")}
</Link>
</FormMessage>
</div>
);
}
function WeaponPool() {
const { t } = useTranslation(["lfg"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("lfg:new.weaponPool.header")}</Label>
<div className="stack horizontal sm">
{data.weaponPool?.map(({ weaponSplId }) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
size={32}
variant="build"
/>
))}
</div>
<FormMessage type="info">
{t("lfg:new.editOn")}{" "}
<Link to={userEditProfilePage(user!)} target="_blank" rel="noreferrer">
{t("lfg:new.weaponPool.userProfile")}
</Link>
</FormMessage>
</div>
);
}