mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-02 22:26:57 -05:00
User search (#1468)
* Initial * Search users from API * Better feeling search * Fix TODO * Search via url, discord id & id * Load initial user * UserSearch on admin page * UserSearch on tournaments page * UserSearch for badges * Plus suggestions * Vod page * Remove unused code * Fix test
This commit is contained in:
parent
9b746969cd
commit
fd7d1ea2dc
|
|
@ -2,8 +2,8 @@ import { Combobox as HeadlessCombobox } from "@headlessui/react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { GearType, UserWithPlusTier } from "~/db/types";
|
import type { GearType } from "~/db/types";
|
||||||
import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr";
|
import { useAllEventsWithMapPools } from "~/hooks/swr";
|
||||||
import { useTranslation } from "~/hooks/useTranslation";
|
import { useTranslation } from "~/hooks/useTranslation";
|
||||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||||
import {
|
import {
|
||||||
|
|
@ -170,70 +170,6 @@ export function Combobox<
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reference for Fuse options: https://fusejs.io/api/options.html
|
|
||||||
const USER_COMBOBOX_FUSE_OPTIONS = {
|
|
||||||
threshold: 0.42, // Empirically determined value to get an exact match for a Discord ID
|
|
||||||
};
|
|
||||||
|
|
||||||
export function UserCombobox({
|
|
||||||
inputName,
|
|
||||||
initialUserId,
|
|
||||||
onChange,
|
|
||||||
userIdsToOmit,
|
|
||||||
className,
|
|
||||||
required,
|
|
||||||
id,
|
|
||||||
}: Pick<
|
|
||||||
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
|
|
||||||
"inputName" | "onChange" | "className" | "id" | "required"
|
|
||||||
> & { userIdsToOmit?: Set<number>; initialUserId?: number }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { users, isLoading, isError } = useUsers();
|
|
||||||
|
|
||||||
const options = React.useMemo(() => {
|
|
||||||
if (!users) return [];
|
|
||||||
|
|
||||||
const data = userIdsToOmit
|
|
||||||
? users.filter((user) => !userIdsToOmit.has(user.id))
|
|
||||||
: users;
|
|
||||||
|
|
||||||
return data.map((u) => ({
|
|
||||||
label: u.discordFullName,
|
|
||||||
value: String(u.id),
|
|
||||||
discordId: u.discordId,
|
|
||||||
plusTier: u.plusTier,
|
|
||||||
}));
|
|
||||||
}, [users, userIdsToOmit]);
|
|
||||||
|
|
||||||
const initialValue = React.useMemo(() => {
|
|
||||||
if (!initialUserId) return;
|
|
||||||
return options.find((o) => o.value === String(initialUserId));
|
|
||||||
}, [options, initialUserId]);
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-error">{t("errors.genericReload")}</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Combobox
|
|
||||||
inputName={inputName}
|
|
||||||
options={options}
|
|
||||||
placeholder="Sendou#4059"
|
|
||||||
isLoading={isLoading}
|
|
||||||
initialValue={initialValue ?? null}
|
|
||||||
onChange={onChange}
|
|
||||||
className={className}
|
|
||||||
id={id}
|
|
||||||
required={required}
|
|
||||||
fuseOptions={USER_COMBOBOX_FUSE_OPTIONS}
|
|
||||||
// reload after users have loaded
|
|
||||||
key={String(!!initialValue)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WeaponCombobox({
|
export function WeaponCombobox({
|
||||||
id,
|
id,
|
||||||
required,
|
required,
|
||||||
|
|
|
||||||
140
app/components/UserSearch.tsx
Normal file
140
app/components/UserSearch.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { Combobox } from "@headlessui/react";
|
||||||
|
import { useFetcher } from "@remix-run/react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useDebounce } from "react-use";
|
||||||
|
import type { UserSearchLoaderData } from "~/routes/u";
|
||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
import { useTranslation } from "~/hooks/useTranslation";
|
||||||
|
|
||||||
|
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
|
||||||
|
|
||||||
|
export function UserSearch({
|
||||||
|
inputName,
|
||||||
|
onChange,
|
||||||
|
initialUserId,
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
userIdsToOmit,
|
||||||
|
required,
|
||||||
|
}: {
|
||||||
|
inputName: string;
|
||||||
|
onChange?: (user: UserSearchUserItem) => void;
|
||||||
|
initialUserId?: number;
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
userIdsToOmit?: Set<number>;
|
||||||
|
required?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedUser, setSelectedUser] =
|
||||||
|
React.useState<UserSearchUserItem | null>(null);
|
||||||
|
const queryFetcher = useFetcher<UserSearchLoaderData>();
|
||||||
|
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
useDebounce(
|
||||||
|
() => {
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
queryFetcher.load(`/u?q=${query}&limit=6`);
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
[query]
|
||||||
|
);
|
||||||
|
|
||||||
|
// load initial user
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (
|
||||||
|
!initialUserId ||
|
||||||
|
initialUserFetcher.state !== "idle" ||
|
||||||
|
initialUserFetcher.data
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialUserFetcher.load(`/u?q=${initialUserId}`);
|
||||||
|
}, [initialUserId, initialUserFetcher]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!initialUserFetcher.data) return;
|
||||||
|
|
||||||
|
setSelectedUser(initialUserFetcher.data.users[0]);
|
||||||
|
}, [initialUserFetcher.data]);
|
||||||
|
|
||||||
|
const allUsers = queryFetcher.data?.users ?? [];
|
||||||
|
|
||||||
|
const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id));
|
||||||
|
const noMatches = queryFetcher.data && users.length === 0;
|
||||||
|
|
||||||
|
const initialSelectionIsLoading = Boolean(
|
||||||
|
initialUserId && !initialUserFetcher.data
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="combobox-wrapper">
|
||||||
|
{selectedUser && inputName ? (
|
||||||
|
<input type="hidden" name={inputName} value={selectedUser.id} />
|
||||||
|
) : null}
|
||||||
|
<Combobox
|
||||||
|
value={selectedUser}
|
||||||
|
onChange={(newUser) => {
|
||||||
|
setSelectedUser(newUser);
|
||||||
|
onChange?.(newUser!);
|
||||||
|
}}
|
||||||
|
disabled={initialSelectionIsLoading}
|
||||||
|
>
|
||||||
|
<Combobox.Input
|
||||||
|
placeholder={
|
||||||
|
initialSelectionIsLoading
|
||||||
|
? t("actions.loading")
|
||||||
|
: "Search via name or ID..."
|
||||||
|
}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
displayValue={(user: UserSearchUserItem) => user?.discordName ?? ""}
|
||||||
|
className={clsx("combobox-input", className)}
|
||||||
|
data-1p-ignore
|
||||||
|
data-testid={`${inputName}-combobox-input`}
|
||||||
|
id={id}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
<Combobox.Options
|
||||||
|
className={clsx("combobox-options", {
|
||||||
|
empty: noMatches,
|
||||||
|
hidden: !queryFetcher.data,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{noMatches ? (
|
||||||
|
<div className="combobox-no-matches">
|
||||||
|
{t("forms.errors.noSearchMatches")}{" "}
|
||||||
|
<span className="combobox-emoji">🤔</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{users.map((user, i) => (
|
||||||
|
<Combobox.Option key={user.id} value={user} as={React.Fragment}>
|
||||||
|
{({ active }) => (
|
||||||
|
<li
|
||||||
|
className={clsx("combobox-item", { active })}
|
||||||
|
data-testid={`combobox-option-${i}`}
|
||||||
|
>
|
||||||
|
<Avatar user={user} size="xs" />
|
||||||
|
<div>
|
||||||
|
<div className="stack xs horizontal items-center">
|
||||||
|
<span className="combobox-username">
|
||||||
|
{user.discordName}
|
||||||
|
</span>{" "}
|
||||||
|
{user.plusTier ? (
|
||||||
|
<span className="text-xxs">+{user.plusTier}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{user.discordUniqueName ? (
|
||||||
|
<div className="text-xs">{user.discordUniqueName}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import findAllPatronsSql from "./findAllPatrons.sql";
|
||||||
import findAllPlusMembersSql from "./findAllPlusMembers.sql";
|
import findAllPlusMembersSql from "./findAllPlusMembers.sql";
|
||||||
import findByIdentifierSql from "./findByIdentifier.sql";
|
import findByIdentifierSql from "./findByIdentifier.sql";
|
||||||
import searchSql from "./search.sql";
|
import searchSql from "./search.sql";
|
||||||
|
import searchExactSql from "./searchExact.sql";
|
||||||
import updateByDiscordIdSql from "./updateByDiscordId.sql";
|
import updateByDiscordIdSql from "./updateByDiscordId.sql";
|
||||||
import updateDiscordIdSql from "./updateDiscordId.sql";
|
import updateDiscordIdSql from "./updateDiscordId.sql";
|
||||||
import updateProfileSql from "./updateProfile.sql";
|
import updateProfileSql from "./updateProfile.sql";
|
||||||
|
|
@ -239,24 +240,71 @@ export const updateResultHighlights = sql.transaction(
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchStm = sql.prepare(searchSql);
|
const searchStm = sql.prepare(searchSql);
|
||||||
export function search(input: string) {
|
export function search({ input, limit }: { input: string; limit: number }) {
|
||||||
const searchString = `%${input}%`;
|
const searchString = `%${input}%`;
|
||||||
|
|
||||||
return searchStm.all({
|
return (
|
||||||
discordName: searchString,
|
searchStm.all({
|
||||||
inGameName: searchString,
|
discordName: searchString,
|
||||||
twitter: searchString,
|
inGameName: searchString,
|
||||||
}) as Array<
|
discordUniqueName: searchString,
|
||||||
Pick<
|
twitter: searchString,
|
||||||
User,
|
limit,
|
||||||
| "discordId"
|
}) as Array<
|
||||||
| "discordAvatar"
|
Pick<
|
||||||
| "discordName"
|
UserWithPlusTier,
|
||||||
| "discordDiscriminator"
|
| "id"
|
||||||
| "customUrl"
|
| "discordId"
|
||||||
| "inGameName"
|
| "discordAvatar"
|
||||||
|
| "discordName"
|
||||||
|
| "discordDiscriminator"
|
||||||
|
| "customUrl"
|
||||||
|
| "inGameName"
|
||||||
|
| "discordUniqueName"
|
||||||
|
| "showDiscordUniqueName"
|
||||||
|
| "plusTier"
|
||||||
|
>
|
||||||
>
|
>
|
||||||
>;
|
).map((user) => ({
|
||||||
|
...user,
|
||||||
|
discordUniqueName: user.showDiscordUniqueName
|
||||||
|
? user.discordUniqueName
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchExactStm = sql.prepare(searchExactSql);
|
||||||
|
export function searchExact(args: {
|
||||||
|
discordId?: User["discordId"];
|
||||||
|
customUrl?: User["customUrl"];
|
||||||
|
id?: User["id"];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
searchExactStm.all({
|
||||||
|
discordId: args.discordId ?? null,
|
||||||
|
customUrl: args.customUrl ?? null,
|
||||||
|
id: args.id ?? null,
|
||||||
|
}) as Array<
|
||||||
|
Pick<
|
||||||
|
UserWithPlusTier,
|
||||||
|
| "id"
|
||||||
|
| "discordId"
|
||||||
|
| "discordAvatar"
|
||||||
|
| "discordName"
|
||||||
|
| "discordDiscriminator"
|
||||||
|
| "customUrl"
|
||||||
|
| "inGameName"
|
||||||
|
| "discordUniqueName"
|
||||||
|
| "showDiscordUniqueName"
|
||||||
|
| "plusTier"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
).map((user) => ({
|
||||||
|
...user,
|
||||||
|
discordUniqueName: user.showDiscordUniqueName
|
||||||
|
? user.discordUniqueName
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const wipePlusTiersStm = sql.prepare(wipePlusTiersSql);
|
const wipePlusTiersStm = sql.prepare(wipePlusTiersSql);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,21 @@
|
||||||
select
|
select
|
||||||
|
"id",
|
||||||
"discordName",
|
"discordName",
|
||||||
"discordId",
|
"discordId",
|
||||||
"discordAvatar",
|
"discordAvatar",
|
||||||
"discordDiscriminator",
|
"discordDiscriminator",
|
||||||
|
"discordUniqueName",
|
||||||
|
"showDiscordUniqueName",
|
||||||
"customUrl",
|
"customUrl",
|
||||||
"inGameName"
|
"inGameName",
|
||||||
|
"PlusTier"."tier" as "plusTier"
|
||||||
from
|
from
|
||||||
"User"
|
"User"
|
||||||
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
|
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
|
||||||
where
|
where
|
||||||
"discordName" like @discordName
|
"discordName" like @discordName
|
||||||
or "inGameName" like @inGameName
|
or "inGameName" like @inGameName
|
||||||
|
or "discordUniqueName" like @discordUniqueName
|
||||||
or "twitter" like @twitter
|
or "twitter" like @twitter
|
||||||
order by
|
order by
|
||||||
case
|
case
|
||||||
|
|
@ -18,4 +23,4 @@ order by
|
||||||
else "PlusTier"."tier"
|
else "PlusTier"."tier"
|
||||||
end asc
|
end asc
|
||||||
limit
|
limit
|
||||||
25
|
@limit
|
||||||
|
|
|
||||||
18
app/db/models/users/searchExact.sql
Normal file
18
app/db/models/users/searchExact.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
select
|
||||||
|
"id",
|
||||||
|
"discordName",
|
||||||
|
"discordId",
|
||||||
|
"discordAvatar",
|
||||||
|
"discordDiscriminator",
|
||||||
|
"discordUniqueName",
|
||||||
|
"showDiscordUniqueName",
|
||||||
|
"customUrl",
|
||||||
|
"inGameName",
|
||||||
|
"PlusTier"."tier" as "plusTier"
|
||||||
|
from
|
||||||
|
"User"
|
||||||
|
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
|
||||||
|
where
|
||||||
|
"discordId" = @discordId
|
||||||
|
or "customUrl" = @customUrl
|
||||||
|
or "id" = @id
|
||||||
|
|
@ -13,7 +13,7 @@ import * as React from "react";
|
||||||
import { useFetcher } from "react-router-dom";
|
import { useFetcher } from "react-router-dom";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { Button } from "~/components/Button";
|
import { Button } from "~/components/Button";
|
||||||
import { Combobox, UserCombobox } from "~/components/Combobox";
|
import { Combobox } from "~/components/Combobox";
|
||||||
import { FormMessage } from "~/components/FormMessage";
|
import { FormMessage } from "~/components/FormMessage";
|
||||||
import { Label } from "~/components/Label";
|
import { Label } from "~/components/Label";
|
||||||
import { Main } from "~/components/Main";
|
import { Main } from "~/components/Main";
|
||||||
|
|
@ -41,6 +41,7 @@ import { addNewArt, editArt } from "../queries/addNewArt.server";
|
||||||
import { findArtById } from "../queries/findArtById.server";
|
import { findArtById } from "../queries/findArtById.server";
|
||||||
import { previewUrl } from "../art-utils";
|
import { previewUrl } from "../art-utils";
|
||||||
import { allArtTags } from "../queries/allArtTags.server";
|
import { allArtTags } from "../queries/allArtTags.server";
|
||||||
|
import { UserSearch } from "~/components/UserSearch";
|
||||||
|
|
||||||
export const handle: SendouRouteHandle = {
|
export const handle: SendouRouteHandle = {
|
||||||
i18n: ["art"],
|
i18n: ["art"],
|
||||||
|
|
@ -419,12 +420,11 @@ function LinkedUsers() {
|
||||||
{users.map(({ inputId, userId }, i) => {
|
{users.map(({ inputId, userId }, i) => {
|
||||||
return (
|
return (
|
||||||
<div key={inputId} className="stack horizontal sm mb-2 items-center">
|
<div key={inputId} className="stack horizontal sm mb-2 items-center">
|
||||||
<UserCombobox
|
<UserSearch
|
||||||
inputName="user"
|
inputName="user"
|
||||||
onChange={(event) => {
|
onChange={(newUser) => {
|
||||||
if (!event) return;
|
|
||||||
const newUsers = clone(users);
|
const newUsers = clone(users);
|
||||||
newUsers[i] = { ...newUsers[i], userId: Number(event.value) };
|
newUsers[i] = { ...newUsers[i], userId: newUser.id };
|
||||||
|
|
||||||
setUsers(newUsers);
|
setUsers(newUsers);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
validateCanCheckIn,
|
validateCanCheckIn,
|
||||||
} from "../tournament-utils";
|
} from "../tournament-utils";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
import { UserCombobox } from "~/components/Combobox";
|
|
||||||
import { adminActionSchema } from "../tournament-schemas.server";
|
import { adminActionSchema } from "../tournament-schemas.server";
|
||||||
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
|
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
|
|
@ -37,6 +36,7 @@ import {
|
||||||
import { Redirect } from "~/components/Redirect";
|
import { Redirect } from "~/components/Redirect";
|
||||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||||
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
|
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
|
||||||
|
import { UserSearch } from "~/components/UserSearch";
|
||||||
|
|
||||||
export const action: ActionFunction = async ({ request, params }) => {
|
export const action: ActionFunction = async ({ request, params }) => {
|
||||||
const user = await requireUserId(request);
|
const user = await requireUserId(request);
|
||||||
|
|
@ -118,7 +118,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||||
validate(team, "Invalid team id");
|
validate(team, "Invalid team id");
|
||||||
|
|
||||||
const previousTeam = teams.find((t) =>
|
const previousTeam = teams.find((t) =>
|
||||||
t.members.some((m) => m.userId === data["user[value]"])
|
t.members.some((m) => m.userId === data.userId)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasTournamentStarted(event.id)) {
|
if (hasTournamentStarted(event.id)) {
|
||||||
|
|
@ -131,7 +131,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
joinTeam({
|
joinTeam({
|
||||||
userId: data["user[value]"],
|
userId: data.userId,
|
||||||
newTeamId: team.id,
|
newTeamId: team.id,
|
||||||
previousTeamId: previousTeam?.id,
|
previousTeamId: previousTeam?.id,
|
||||||
// this team is not checked in so we can simply delete it
|
// this team is not checked in so we can simply delete it
|
||||||
|
|
@ -329,7 +329,7 @@ function AdminActions() {
|
||||||
{selectedAction.inputs.includes("USER") ? (
|
{selectedAction.inputs.includes("USER") ? (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="user">User</label>
|
<label htmlFor="user">User</label>
|
||||||
<UserCombobox inputName="user" id="user" />
|
<UserSearch inputName="userId" id="user" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export const adminActionSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("ADD_MEMBER"),
|
_action: _action("ADD_MEMBER"),
|
||||||
teamId: id,
|
teamId: id,
|
||||||
"user[value]": id,
|
userId: id,
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("REMOVE_MEMBER"),
|
_action: _action("REMOVE_MEMBER"),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { Form, useLoaderData } from "@remix-run/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "~/components/Button";
|
import { Button } from "~/components/Button";
|
||||||
import { UserCombobox, WeaponCombobox } from "~/components/Combobox";
|
import { WeaponCombobox } from "~/components/Combobox";
|
||||||
import { Input } from "~/components/Input";
|
import { Input } from "~/components/Input";
|
||||||
import { Label } from "~/components/Label";
|
import { Label } from "~/components/Label";
|
||||||
import { Main } from "~/components/Main";
|
import { Main } from "~/components/Main";
|
||||||
|
|
@ -41,6 +41,7 @@ import { videoMatchTypes, VOD } from "../vods-constants";
|
||||||
import { videoInputSchema } from "../vods-schemas";
|
import { videoInputSchema } from "../vods-schemas";
|
||||||
import type { VideoBeingAdded, VideoMatchBeingAdded } from "../vods-types";
|
import type { VideoBeingAdded, VideoMatchBeingAdded } from "../vods-types";
|
||||||
import { canAddVideo, canEditVideo, vodToVideoBeingAdded } from "../vods-utils";
|
import { canAddVideo, canEditVideo, vodToVideoBeingAdded } from "../vods-utils";
|
||||||
|
import { UserSearch } from "~/components/UserSearch";
|
||||||
|
|
||||||
export const handle: SendouRouteHandle = {
|
export const handle: SendouRouteHandle = {
|
||||||
i18n: ["vods", "calendar"],
|
i18n: ["vods", "calendar"],
|
||||||
|
|
@ -387,14 +388,14 @@ function TransformingPlayerInput({
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UserCombobox
|
<UserSearch
|
||||||
id="pov"
|
id="pov"
|
||||||
inputName="team-player"
|
inputName="team-player"
|
||||||
initialUserId={match.povUserId}
|
initialUserId={match.povUserId}
|
||||||
onChange={(selected) =>
|
onChange={(newUser) =>
|
||||||
onChange({
|
onChange({
|
||||||
...match,
|
...match,
|
||||||
povUserId: selected?.value ? Number(selected.value) : undefined,
|
povUserId: newUser.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ import useSWRImmutable from "swr/immutable";
|
||||||
import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage";
|
import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage";
|
||||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||||
import type { EventsWithMapPoolsLoaderData } from "~/routes/calendar/map-pool-events";
|
import type { EventsWithMapPoolsLoaderData } from "~/routes/calendar/map-pool-events";
|
||||||
import type { UsersLoaderData } from "~/routes/users";
|
|
||||||
import {
|
import {
|
||||||
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
|
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
|
||||||
GET_ALL_USERS_ROUTE,
|
|
||||||
getWeaponUsage,
|
getWeaponUsage,
|
||||||
} from "~/utils/urls";
|
} from "~/utils/urls";
|
||||||
|
|
||||||
|
|
@ -14,19 +12,6 @@ const fetcher = async (url: string) => {
|
||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useUsers() {
|
|
||||||
const { data, error } = useSWRImmutable<UsersLoaderData>(
|
|
||||||
GET_ALL_USERS_ROUTE,
|
|
||||||
fetcher
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
users: data?.users,
|
|
||||||
isLoading: !error && !data,
|
|
||||||
isError: error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAllEventsWithMapPools() {
|
export function useAllEventsWithMapPools() {
|
||||||
const { data, error } = useSWRImmutable<EventsWithMapPoolsLoaderData>(
|
const { data, error } = useSWRImmutable<EventsWithMapPoolsLoaderData>(
|
||||||
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
|
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ import * as React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "~/components/Button";
|
import { Button } from "~/components/Button";
|
||||||
import { Catcher } from "~/components/Catcher";
|
import { Catcher } from "~/components/Catcher";
|
||||||
import { UserCombobox } from "~/components/Combobox";
|
|
||||||
import { Main } from "~/components/Main";
|
import { Main } from "~/components/Main";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
|
import { UserSearch } from "~/components/UserSearch";
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import { makeArtist } from "~/features/art";
|
import { makeArtist } from "~/features/art";
|
||||||
import {
|
import {
|
||||||
|
|
@ -38,29 +38,29 @@ export const meta: V2_MetaFunction = () => {
|
||||||
const adminActionSchema = z.union([
|
const adminActionSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("MIGRATE"),
|
_action: _action("MIGRATE"),
|
||||||
"old-user[value]": z.preprocess(actualNumber, z.number().positive()),
|
"old-user": z.preprocess(actualNumber, z.number().positive()),
|
||||||
"new-user[value]": z.preprocess(actualNumber, z.number().positive()),
|
"new-user": z.preprocess(actualNumber, z.number().positive()),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("REFRESH"),
|
_action: _action("REFRESH"),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("FORCE_PATRON"),
|
_action: _action("FORCE_PATRON"),
|
||||||
"user[value]": z.preprocess(actualNumber, z.number().positive()),
|
user: z.preprocess(actualNumber, z.number().positive()),
|
||||||
patronTier: z.preprocess(actualNumber, z.number()),
|
patronTier: z.preprocess(actualNumber, z.number()),
|
||||||
patronTill: z.string(),
|
patronTill: z.string(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("VIDEO_ADDER"),
|
_action: _action("VIDEO_ADDER"),
|
||||||
"user[value]": z.preprocess(actualNumber, z.number().positive()),
|
user: z.preprocess(actualNumber, z.number().positive()),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("ARTIST"),
|
_action: _action("ARTIST"),
|
||||||
"user[value]": z.preprocess(actualNumber, z.number().positive()),
|
user: z.preprocess(actualNumber, z.number().positive()),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
_action: _action("LINK_PLAYER"),
|
_action: _action("LINK_PLAYER"),
|
||||||
"user[value]": z.preprocess(actualNumber, z.number().positive()),
|
user: z.preprocess(actualNumber, z.number().positive()),
|
||||||
playerId: z.preprocess(actualNumber, z.number().positive()),
|
playerId: z.preprocess(actualNumber, z.number().positive()),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
@ -77,8 +77,8 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
switch (data._action) {
|
switch (data._action) {
|
||||||
case "MIGRATE": {
|
case "MIGRATE": {
|
||||||
db.users.migrate({
|
db.users.migrate({
|
||||||
oldUserId: data["old-user[value]"],
|
oldUserId: data["old-user"],
|
||||||
newUserId: data["new-user[value]"],
|
newUserId: data["new-user"],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -88,7 +88,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
}
|
}
|
||||||
case "FORCE_PATRON": {
|
case "FORCE_PATRON": {
|
||||||
db.users.forcePatron({
|
db.users.forcePatron({
|
||||||
id: data["user[value]"],
|
id: data["user"],
|
||||||
patronSince: dateToDatabaseTimestamp(new Date()),
|
patronSince: dateToDatabaseTimestamp(new Date()),
|
||||||
patronTier: data.patronTier,
|
patronTier: data.patronTier,
|
||||||
patronTill: dateToDatabaseTimestamp(new Date(data.patronTill)),
|
patronTill: dateToDatabaseTimestamp(new Date(data.patronTill)),
|
||||||
|
|
@ -96,16 +96,16 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "ARTIST": {
|
case "ARTIST": {
|
||||||
makeArtist(data["user[value]"]);
|
makeArtist(data["user"]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "VIDEO_ADDER": {
|
case "VIDEO_ADDER": {
|
||||||
db.users.makeVideoAdder(data["user[value]"]);
|
db.users.makeVideoAdder(data["user"]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "LINK_PLAYER": {
|
case "LINK_PLAYER": {
|
||||||
db.users.linkPlayer({
|
db.users.linkPlayer({
|
||||||
userId: data["user[value]"],
|
userId: data["user"],
|
||||||
playerId: data.playerId,
|
playerId: data.playerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -169,11 +169,9 @@ function Impersonate() {
|
||||||
<h2>Impersonate user</h2>
|
<h2>Impersonate user</h2>
|
||||||
<div>
|
<div>
|
||||||
<label>User to log in as</label>
|
<label>User to log in as</label>
|
||||||
<UserCombobox
|
<UserSearch
|
||||||
inputName="user"
|
inputName="user"
|
||||||
onChange={(selected) =>
|
onChange={(newUser) => setUserId(newUser.id)}
|
||||||
setUserId(selected?.value ? Number(selected.value) : undefined)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack horizontal md">
|
<div className="stack horizontal md">
|
||||||
|
|
@ -209,20 +207,16 @@ function MigrateUser() {
|
||||||
<div className="stack horizontal md">
|
<div className="stack horizontal md">
|
||||||
<div>
|
<div>
|
||||||
<label>Old user</label>
|
<label>Old user</label>
|
||||||
<UserCombobox
|
<UserSearch
|
||||||
inputName="old-user"
|
inputName="old-user"
|
||||||
onChange={(selected) =>
|
onChange={(newUser) => setOldUserId(newUser.id)}
|
||||||
setOldUserId(selected?.value ? Number(selected.value) : undefined)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>New user</label>
|
<label>New user</label>
|
||||||
<UserCombobox
|
<UserSearch
|
||||||
inputName="new-user"
|
inputName="new-user"
|
||||||
onChange={(selected) =>
|
onChange={(newUser) => setNewUserId(newUser.id)}
|
||||||
setNewUserId(selected?.value ? Number(selected.value) : undefined)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -249,7 +243,7 @@ function LinkPlayer() {
|
||||||
<div className="stack horizontal md">
|
<div className="stack horizontal md">
|
||||||
<div>
|
<div>
|
||||||
<label>User</label>
|
<label>User</label>
|
||||||
<UserCombobox inputName="user" />
|
<UserSearch inputName="user" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Player ID</label>
|
<label>Player ID</label>
|
||||||
|
|
@ -274,7 +268,7 @@ function GiveArtist() {
|
||||||
<div className="stack horizontal md">
|
<div className="stack horizontal md">
|
||||||
<div>
|
<div>
|
||||||
<label>User</label>
|
<label>User</label>
|
||||||
<UserCombobox inputName="user" />
|
<UserSearch inputName="user" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack horizontal md">
|
<div className="stack horizontal md">
|
||||||
|
|
@ -295,7 +289,7 @@ function GiveVideoAdder() {
|
||||||
<div className="stack horizontal md">
|
<div className="stack horizontal md">
|
||||||
<div>
|
<div>
|
||||||
<label>User</label>
|
<label>User</label>
|
||||||
<UserCombobox inputName="user" />
|
<UserSearch inputName="user" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stack horizontal md">
|
<div className="stack horizontal md">
|
||||||
|
|
@ -316,7 +310,7 @@ function ForcePatron() {
|
||||||
<div className="stack horizontal md">
|
<div className="stack horizontal md">
|
||||||
<div>
|
<div>
|
||||||
<label>User</label>
|
<label>User</label>
|
||||||
<UserCombobox inputName="user" />
|
<UserSearch inputName="user" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { Form, useMatches, useOutletContext } from "@remix-run/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button, LinkButton } from "~/components/Button";
|
import { Button, LinkButton } from "~/components/Button";
|
||||||
import { UserCombobox } from "~/components/Combobox";
|
|
||||||
import { Dialog } from "~/components/Dialog";
|
import { Dialog } from "~/components/Dialog";
|
||||||
import { TrashIcon } from "~/components/icons/Trash";
|
import { TrashIcon } from "~/components/icons/Trash";
|
||||||
import { Label } from "~/components/Label";
|
import { Label } from "~/components/Label";
|
||||||
|
|
@ -26,6 +25,7 @@ import {
|
||||||
safeJSONParse,
|
safeJSONParse,
|
||||||
} from "~/utils/zod";
|
} from "~/utils/zod";
|
||||||
import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "../$id";
|
import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "../$id";
|
||||||
|
import { UserSearch } from "~/components/UserSearch";
|
||||||
|
|
||||||
const editBadgeActionSchema = z.union([
|
const editBadgeActionSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -131,15 +131,13 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
|
||||||
<h3 className="badges-edit__small-header">Managers</h3>
|
<h3 className="badges-edit__small-header">Managers</h3>
|
||||||
<div className="text-center my-4">
|
<div className="text-center my-4">
|
||||||
<Label className="stack vertical items-center">Add new manager</Label>
|
<Label className="stack vertical items-center">Add new manager</Label>
|
||||||
<UserCombobox
|
<UserSearch
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
inputName="new-manager"
|
inputName="new-manager"
|
||||||
onChange={(user) => {
|
onChange={(user) => {
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
setManagers([
|
setManagers([
|
||||||
...managers,
|
...managers,
|
||||||
{ discordFullName: user.label, id: Number(user.value) },
|
{ discordFullName: user.discordName, id: user.id },
|
||||||
]);
|
]);
|
||||||
}}
|
}}
|
||||||
userIdsToOmit={userIdsToOmitFromCombobox}
|
userIdsToOmit={userIdsToOmitFromCombobox}
|
||||||
|
|
@ -208,17 +206,15 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
|
||||||
<h3 className="badges-edit__small-header">Owners</h3>
|
<h3 className="badges-edit__small-header">Owners</h3>
|
||||||
<div className="text-center my-4">
|
<div className="text-center my-4">
|
||||||
<Label className="stack items-center">Add new owner</Label>
|
<Label className="stack items-center">Add new owner</Label>
|
||||||
<UserCombobox
|
<UserSearch
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
inputName="new-owner"
|
inputName="new-owner"
|
||||||
onChange={(user) => {
|
onChange={(user) => {
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
setOwners([
|
setOwners([
|
||||||
...owners,
|
...owners,
|
||||||
{
|
{
|
||||||
discordFullName: user.label,
|
discordFullName: user.discordName,
|
||||||
id: Number(user.value),
|
id: user.id,
|
||||||
count: 1,
|
count: 1,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ import clsx from "clsx";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "~/components/Button";
|
import { Button } from "~/components/Button";
|
||||||
import { UserCombobox } from "~/components/Combobox";
|
|
||||||
import { FormErrors } from "~/components/FormErrors";
|
import { FormErrors } from "~/components/FormErrors";
|
||||||
import { FormMessage } from "~/components/FormMessage";
|
import { FormMessage } from "~/components/FormMessage";
|
||||||
import { Label } from "~/components/Label";
|
import { Label } from "~/components/Label";
|
||||||
import { Main } from "~/components/Main";
|
import { Main } from "~/components/Main";
|
||||||
|
import { UserSearch } from "~/components/UserSearch";
|
||||||
import { CALENDAR_EVENT_RESULT } from "~/constants";
|
import { CALENDAR_EVENT_RESULT } from "~/constants";
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import type { User } from "~/db/types";
|
import type { User } from "~/db/types";
|
||||||
|
|
@ -412,16 +412,11 @@ function Players({
|
||||||
max={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
|
max={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UserCombobox
|
<UserSearch
|
||||||
id={formId}
|
id={formId}
|
||||||
inputName="team-player"
|
inputName="team-player"
|
||||||
initialUserId={player.id}
|
initialUserId={player.id}
|
||||||
onChange={(selected) =>
|
onChange={(newUser) => handleInputChange(i, newUser.id)}
|
||||||
handleInputChange(
|
|
||||||
i,
|
|
||||||
selected?.value ? Number(selected?.value) : NEW_PLAYER.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH,
|
PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH,
|
||||||
PLUS_TIERS,
|
PLUS_TIERS,
|
||||||
} from "~/constants";
|
} from "~/constants";
|
||||||
import { UserCombobox } from "~/components/Combobox";
|
|
||||||
import type { ActionFunction } from "@remix-run/node";
|
import type { ActionFunction } from "@remix-run/node";
|
||||||
import { redirect } from "@remix-run/node";
|
import { redirect } from "@remix-run/node";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -33,6 +32,7 @@ import { FormMessage } from "~/components/FormMessage";
|
||||||
import { atOrError } from "~/utils/arrays";
|
import { atOrError } from "~/utils/arrays";
|
||||||
import { requireUser, useUser } from "~/modules/auth";
|
import { requireUser, useUser } from "~/modules/auth";
|
||||||
import { SubmitButton } from "~/components/SubmitButton";
|
import { SubmitButton } from "~/components/SubmitButton";
|
||||||
|
import { UserSearch } from "~/components/UserSearch";
|
||||||
|
|
||||||
const commentActionSchema = z.object({
|
const commentActionSchema = z.object({
|
||||||
tier: z.preprocess(
|
tier: z.preprocess(
|
||||||
|
|
@ -46,7 +46,7 @@ const commentActionSchema = z.object({
|
||||||
trimmedString,
|
trimmedString,
|
||||||
z.string().min(1).max(PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH)
|
z.string().min(1).max(PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH)
|
||||||
),
|
),
|
||||||
"user[value]": z.preprocess(actualNumber, z.number().positive()),
|
userId: z.preprocess(actualNumber, z.number().positive()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const action: ActionFunction = async ({ request }) => {
|
export const action: ActionFunction = async ({ request }) => {
|
||||||
|
|
@ -55,9 +55,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
schema: commentActionSchema,
|
schema: commentActionSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const suggested = badRequestIfFalsy(
|
const suggested = badRequestIfFalsy(db.users.findByIdentifier(data.userId));
|
||||||
db.users.findByIdentifier(data["user[value]"])
|
|
||||||
);
|
|
||||||
|
|
||||||
const user = await requireUser(request);
|
const user = await requireUser(request);
|
||||||
|
|
||||||
|
|
@ -148,7 +146,16 @@ export default function PlusNewSuggestionModalPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="user">Suggested user</label>
|
<label htmlFor="user">Suggested user</label>
|
||||||
<UserCombobox inputName="user" onChange={setSelectedUser} required />
|
<UserSearch
|
||||||
|
inputName="userId"
|
||||||
|
onChange={(user) =>
|
||||||
|
setSelectedUser({
|
||||||
|
plusTier: user.plusTier,
|
||||||
|
value: String(user.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
{selectedUserErrorMessage ? (
|
{selectedUserErrorMessage ? (
|
||||||
<FormMessage type="error">{selectedUserErrorMessage}</FormMessage>
|
<FormMessage type="error">{selectedUserErrorMessage}</FormMessage>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { LinksFunction, LoaderArgs } from "@remix-run/node";
|
import type { LinksFunction, LoaderArgs, SerializeFrom } from "@remix-run/node";
|
||||||
import { Main } from "~/components/Main";
|
import { Main } from "~/components/Main";
|
||||||
import type { SendouRouteHandle } from "~/utils/remix";
|
import { parseSearchParams, type SendouRouteHandle } from "~/utils/remix";
|
||||||
import { navIconUrl, userPage, USER_SEARCH_PAGE } from "~/utils/urls";
|
import { navIconUrl, userPage, USER_SEARCH_PAGE } from "~/utils/urls";
|
||||||
import styles from "~/styles/u.css";
|
import styles from "~/styles/u.css";
|
||||||
import { Input } from "~/components/Input";
|
import { Input } from "~/components/Input";
|
||||||
|
|
@ -12,6 +12,8 @@ import * as React from "react";
|
||||||
import { Avatar } from "~/components/Avatar";
|
import { Avatar } from "~/components/Avatar";
|
||||||
import { discordFullName } from "~/utils/strings";
|
import { discordFullName } from "~/utils/strings";
|
||||||
import { useTranslation } from "~/hooks/useTranslation";
|
import { useTranslation } from "~/hooks/useTranslation";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { queryToUserIdentifier } from "~/utils/users";
|
||||||
|
|
||||||
export const links: LinksFunction = () => {
|
export const links: LinksFunction = () => {
|
||||||
return [{ rel: "stylesheet", href: styles }];
|
return [{ rel: "stylesheet", href: styles }];
|
||||||
|
|
@ -26,13 +28,29 @@ export const handle: SendouRouteHandle = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UserSearchLoaderData = SerializeFrom<typeof loader>;
|
||||||
|
|
||||||
|
const searchParamsSchema = z.object({
|
||||||
|
q: z.string().max(100).default(""),
|
||||||
|
limit: z.coerce.number().int().min(1).max(25).default(25),
|
||||||
|
});
|
||||||
|
|
||||||
export const loader = ({ request }: LoaderArgs) => {
|
export const loader = ({ request }: LoaderArgs) => {
|
||||||
const url = new URL(request.url);
|
const { q, limit } = parseSearchParams({
|
||||||
const input = url.searchParams.get("q");
|
request,
|
||||||
|
schema: searchParamsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
if (!input) return null;
|
if (!q) return null;
|
||||||
|
|
||||||
return { users: db.users.search(input), input };
|
const identifier = queryToUserIdentifier(q);
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: identifier
|
||||||
|
? db.users.searchExact(identifier)
|
||||||
|
: db.users.search({ input: q, limit }),
|
||||||
|
input: q,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UserSearchPage() {
|
export default function UserSearchPage() {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
|
import type { ActionFunction } from "@remix-run/node";
|
||||||
import { json } from "@remix-run/node";
|
|
||||||
import { db } from "~/db";
|
import { db } from "~/db";
|
||||||
import type { UserWithPlusTier } from "~/db/types";
|
|
||||||
import { canAccessLohiEndpoint } from "~/permissions";
|
import { canAccessLohiEndpoint } from "~/permissions";
|
||||||
import { discordFullName } from "~/utils/strings";
|
|
||||||
|
|
||||||
export const action: ActionFunction = async ({ request }) => {
|
export const action: ActionFunction = async ({ request }) => {
|
||||||
if (!canAccessLohiEndpoint(request)) {
|
if (!canAccessLohiEndpoint(request)) {
|
||||||
|
|
@ -15,20 +12,3 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface UsersLoaderData {
|
|
||||||
users: ({
|
|
||||||
discordFullName: string;
|
|
||||||
} & Pick<UserWithPlusTier, "id" | "discordId" | "plusTier">)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loader: LoaderFunction = () => {
|
|
||||||
return json<UsersLoaderData>({
|
|
||||||
users: db.users.findAll().map((u) => ({
|
|
||||||
id: u.id,
|
|
||||||
discordFullName: discordFullName(u),
|
|
||||||
discordId: u.discordId,
|
|
||||||
plusTier: u.plusTier,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -615,6 +615,13 @@ dialog::backdrop {
|
||||||
background-color: var(--theme-transparent);
|
background-color: var(--theme-transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.combobox-username {
|
||||||
|
max-width: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.article > p {
|
.article > p {
|
||||||
padding-block: var(--s-2-5);
|
padding-block: var(--s-2-5);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export async function selectUser({
|
||||||
|
|
||||||
await combobox.clear();
|
await combobox.clear();
|
||||||
await combobox.type(userName);
|
await combobox.type(userName);
|
||||||
|
await expect(page.getByTestId("combobox-option-0")).toBeVisible();
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,6 @@ export const FRONT_GIRL_PATH = "/static-assets/img/layout/front-girl";
|
||||||
export const FRONT_BOY_BG_PATH = "/static-assets/img/layout/front-boy-bg";
|
export const FRONT_BOY_BG_PATH = "/static-assets/img/layout/front-boy-bg";
|
||||||
export const FRONT_GIRL_BG_PATH = "/static-assets/img/layout/front-girl-bg";
|
export const FRONT_GIRL_BG_PATH = "/static-assets/img/layout/front-girl-bg";
|
||||||
|
|
||||||
export const GET_ALL_USERS_ROUTE = "/users";
|
|
||||||
export const GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE = "/calendar/map-pool-events";
|
export const GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE = "/calendar/map-pool-events";
|
||||||
|
|
||||||
interface UserLinkArgs {
|
interface UserLinkArgs {
|
||||||
|
|
|
||||||
44
app/utils/users.test.ts
Normal file
44
app/utils/users.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { suite } from "uvu";
|
||||||
|
import * as assert from "uvu/assert";
|
||||||
|
import { queryToUserIdentifier } from "./users";
|
||||||
|
|
||||||
|
const QueryToUserIdentifier = suite("queryToUserIdentifier()");
|
||||||
|
|
||||||
|
QueryToUserIdentifier("returns null if no match", () => {
|
||||||
|
assert.equal(queryToUserIdentifier("foo"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryToUserIdentifier("gets custom url from url", () => {
|
||||||
|
assert.equal(queryToUserIdentifier("https://sendou.ink/u/sendou"), {
|
||||||
|
customUrl: "sendou",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryToUserIdentifier("gets discord id from url", () => {
|
||||||
|
assert.equal(
|
||||||
|
queryToUserIdentifier("https://sendou.ink/u/79237403620945920"),
|
||||||
|
{
|
||||||
|
discordId: "79237403620945920",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryToUserIdentifier("gets custom url from url (without https://)", () => {
|
||||||
|
assert.equal(queryToUserIdentifier("sendou.ink/u/sendou"), {
|
||||||
|
customUrl: "sendou",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryToUserIdentifier("gets discord id", () => {
|
||||||
|
assert.equal(queryToUserIdentifier("79237403620945920"), {
|
||||||
|
discordId: "79237403620945920",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryToUserIdentifier("gets id", () => {
|
||||||
|
assert.equal(queryToUserIdentifier("1"), {
|
||||||
|
id: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
QueryToUserIdentifier.run();
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { User } from "~/db/types";
|
import type { User } from "~/db/types";
|
||||||
import { isAdmin } from "~/permissions";
|
import { isAdmin } from "~/permissions";
|
||||||
|
import { isCustomUrl } from "./urls";
|
||||||
|
|
||||||
export function isAtLeastFiveDollarTierPatreon(
|
export function isAtLeastFiveDollarTierPatreon(
|
||||||
user?: Pick<User, "patronTier" | "id">
|
user?: Pick<User, "patronTier" | "id">
|
||||||
|
|
@ -8,3 +9,32 @@ export function isAtLeastFiveDollarTierPatreon(
|
||||||
|
|
||||||
return isAdmin(user) || (user.patronTier && user.patronTier >= 2);
|
return isAdmin(user) || (user.patronTier && user.patronTier >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urlRegExp = new RegExp("(https://)?sendou.ink/u/(.+)");
|
||||||
|
const DISCORD_ID_MIN_LENGTH = 17;
|
||||||
|
export function queryToUserIdentifier(
|
||||||
|
query: string
|
||||||
|
): { id: number } | { discordId: string } | { customUrl: string } | null {
|
||||||
|
const match = query.match(urlRegExp);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const [, , identifier] = match;
|
||||||
|
|
||||||
|
if (isCustomUrl(identifier)) {
|
||||||
|
return { customUrl: identifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { discordId: identifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
// = it's numeric
|
||||||
|
if (!isCustomUrl(query)) {
|
||||||
|
if (query.length >= DISCORD_ID_MIN_LENGTH) {
|
||||||
|
return { discordId: query };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: Number(query) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user