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 Fuse from "fuse.js";
|
||||
import * as React from "react";
|
||||
import type { GearType, UserWithPlusTier } from "~/db/types";
|
||||
import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr";
|
||||
import type { GearType } from "~/db/types";
|
||||
import { useAllEventsWithMapPools } from "~/hooks/swr";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
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({
|
||||
id,
|
||||
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 findByIdentifierSql from "./findByIdentifier.sql";
|
||||
import searchSql from "./search.sql";
|
||||
import searchExactSql from "./searchExact.sql";
|
||||
import updateByDiscordIdSql from "./updateByDiscordId.sql";
|
||||
import updateDiscordIdSql from "./updateDiscordId.sql";
|
||||
import updateProfileSql from "./updateProfile.sql";
|
||||
|
|
@ -239,24 +240,71 @@ export const updateResultHighlights = sql.transaction(
|
|||
);
|
||||
|
||||
const searchStm = sql.prepare(searchSql);
|
||||
export function search(input: string) {
|
||||
export function search({ input, limit }: { input: string; limit: number }) {
|
||||
const searchString = `%${input}%`;
|
||||
|
||||
return searchStm.all({
|
||||
discordName: searchString,
|
||||
inGameName: searchString,
|
||||
twitter: searchString,
|
||||
}) as Array<
|
||||
Pick<
|
||||
User,
|
||||
| "discordId"
|
||||
| "discordAvatar"
|
||||
| "discordName"
|
||||
| "discordDiscriminator"
|
||||
| "customUrl"
|
||||
| "inGameName"
|
||||
return (
|
||||
searchStm.all({
|
||||
discordName: searchString,
|
||||
inGameName: searchString,
|
||||
discordUniqueName: searchString,
|
||||
twitter: searchString,
|
||||
limit,
|
||||
}) as Array<
|
||||
Pick<
|
||||
UserWithPlusTier,
|
||||
| "id"
|
||||
| "discordId"
|
||||
| "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);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
select
|
||||
"id",
|
||||
"discordName",
|
||||
"discordId",
|
||||
"discordAvatar",
|
||||
"discordDiscriminator",
|
||||
"discordUniqueName",
|
||||
"showDiscordUniqueName",
|
||||
"customUrl",
|
||||
"inGameName"
|
||||
"inGameName",
|
||||
"PlusTier"."tier" as "plusTier"
|
||||
from
|
||||
"User"
|
||||
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
|
||||
where
|
||||
"discordName" like @discordName
|
||||
or "inGameName" like @inGameName
|
||||
or "discordUniqueName" like @discordUniqueName
|
||||
or "twitter" like @twitter
|
||||
order by
|
||||
case
|
||||
|
|
@ -18,4 +23,4 @@ order by
|
|||
else "PlusTier"."tier"
|
||||
end asc
|
||||
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 invariant from "tiny-invariant";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Combobox, UserCombobox } from "~/components/Combobox";
|
||||
import { Combobox } from "~/components/Combobox";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
|
|
@ -41,6 +41,7 @@ import { addNewArt, editArt } from "../queries/addNewArt.server";
|
|||
import { findArtById } from "../queries/findArtById.server";
|
||||
import { previewUrl } from "../art-utils";
|
||||
import { allArtTags } from "../queries/allArtTags.server";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["art"],
|
||||
|
|
@ -419,12 +420,11 @@ function LinkedUsers() {
|
|||
{users.map(({ inputId, userId }, i) => {
|
||||
return (
|
||||
<div key={inputId} className="stack horizontal sm mb-2 items-center">
|
||||
<UserCombobox
|
||||
<UserSearch
|
||||
inputName="user"
|
||||
onChange={(event) => {
|
||||
if (!event) return;
|
||||
onChange={(newUser) => {
|
||||
const newUsers = clone(users);
|
||||
newUsers[i] = { ...newUsers[i], userId: Number(event.value) };
|
||||
newUsers[i] = { ...newUsers[i], userId: newUser.id };
|
||||
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
validateCanCheckIn,
|
||||
} from "../tournament-utils";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserCombobox } from "~/components/Combobox";
|
||||
import { adminActionSchema } from "../tournament-schemas.server";
|
||||
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
|
||||
import invariant from "tiny-invariant";
|
||||
|
|
@ -37,6 +36,7 @@ import {
|
|||
import { Redirect } from "~/components/Redirect";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
|
|
@ -118,7 +118,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
validate(team, "Invalid team id");
|
||||
|
||||
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)) {
|
||||
|
|
@ -131,7 +131,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
}
|
||||
|
||||
joinTeam({
|
||||
userId: data["user[value]"],
|
||||
userId: data.userId,
|
||||
newTeamId: team.id,
|
||||
previousTeamId: previousTeam?.id,
|
||||
// this team is not checked in so we can simply delete it
|
||||
|
|
@ -329,7 +329,7 @@ function AdminActions() {
|
|||
{selectedAction.inputs.includes("USER") ? (
|
||||
<div>
|
||||
<label htmlFor="user">User</label>
|
||||
<UserCombobox inputName="user" id="user" />
|
||||
<UserSearch inputName="userId" id="user" />
|
||||
</div>
|
||||
) : null}
|
||||
<SubmitButton
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export const adminActionSchema = z.union([
|
|||
z.object({
|
||||
_action: _action("ADD_MEMBER"),
|
||||
teamId: id,
|
||||
"user[value]": id,
|
||||
userId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("REMOVE_MEMBER"),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Form, useLoaderData } from "@remix-run/react";
|
|||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { UserCombobox, WeaponCombobox } from "~/components/Combobox";
|
||||
import { WeaponCombobox } from "~/components/Combobox";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
|
|
@ -41,6 +41,7 @@ import { videoMatchTypes, VOD } from "../vods-constants";
|
|||
import { videoInputSchema } from "../vods-schemas";
|
||||
import type { VideoBeingAdded, VideoMatchBeingAdded } from "../vods-types";
|
||||
import { canAddVideo, canEditVideo, vodToVideoBeingAdded } from "../vods-utils";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["vods", "calendar"],
|
||||
|
|
@ -387,14 +388,14 @@ function TransformingPlayerInput({
|
|||
required
|
||||
/>
|
||||
) : (
|
||||
<UserCombobox
|
||||
<UserSearch
|
||||
id="pov"
|
||||
inputName="team-player"
|
||||
initialUserId={match.povUserId}
|
||||
onChange={(selected) =>
|
||||
onChange={(newUser) =>
|
||||
onChange({
|
||||
...match,
|
||||
povUserId: selected?.value ? Number(selected.value) : undefined,
|
||||
povUserId: newUser.id,
|
||||
})
|
||||
}
|
||||
required
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@ import useSWRImmutable from "swr/immutable";
|
|||
import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import type { EventsWithMapPoolsLoaderData } from "~/routes/calendar/map-pool-events";
|
||||
import type { UsersLoaderData } from "~/routes/users";
|
||||
import {
|
||||
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
|
||||
GET_ALL_USERS_ROUTE,
|
||||
getWeaponUsage,
|
||||
} from "~/utils/urls";
|
||||
|
||||
|
|
@ -14,19 +12,6 @@ const fetcher = async (url: string) => {
|
|||
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() {
|
||||
const { data, error } = useSWRImmutable<EventsWithMapPoolsLoaderData>(
|
||||
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import * as React from "react";
|
|||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Catcher } from "~/components/Catcher";
|
||||
import { UserCombobox } from "~/components/Combobox";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { db } from "~/db";
|
||||
import { makeArtist } from "~/features/art";
|
||||
import {
|
||||
|
|
@ -38,29 +38,29 @@ export const meta: V2_MetaFunction = () => {
|
|||
const adminActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("MIGRATE"),
|
||||
"old-user[value]": z.preprocess(actualNumber, z.number().positive()),
|
||||
"new-user[value]": z.preprocess(actualNumber, z.number().positive()),
|
||||
"old-user": z.preprocess(actualNumber, z.number().positive()),
|
||||
"new-user": z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("REFRESH"),
|
||||
}),
|
||||
z.object({
|
||||
_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()),
|
||||
patronTill: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("VIDEO_ADDER"),
|
||||
"user[value]": z.preprocess(actualNumber, z.number().positive()),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("ARTIST"),
|
||||
"user[value]": z.preprocess(actualNumber, z.number().positive()),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_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()),
|
||||
}),
|
||||
]);
|
||||
|
|
@ -77,8 +77,8 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
switch (data._action) {
|
||||
case "MIGRATE": {
|
||||
db.users.migrate({
|
||||
oldUserId: data["old-user[value]"],
|
||||
newUserId: data["new-user[value]"],
|
||||
oldUserId: data["old-user"],
|
||||
newUserId: data["new-user"],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
}
|
||||
case "FORCE_PATRON": {
|
||||
db.users.forcePatron({
|
||||
id: data["user[value]"],
|
||||
id: data["user"],
|
||||
patronSince: dateToDatabaseTimestamp(new Date()),
|
||||
patronTier: data.patronTier,
|
||||
patronTill: dateToDatabaseTimestamp(new Date(data.patronTill)),
|
||||
|
|
@ -96,16 +96,16 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
break;
|
||||
}
|
||||
case "ARTIST": {
|
||||
makeArtist(data["user[value]"]);
|
||||
makeArtist(data["user"]);
|
||||
break;
|
||||
}
|
||||
case "VIDEO_ADDER": {
|
||||
db.users.makeVideoAdder(data["user[value]"]);
|
||||
db.users.makeVideoAdder(data["user"]);
|
||||
break;
|
||||
}
|
||||
case "LINK_PLAYER": {
|
||||
db.users.linkPlayer({
|
||||
userId: data["user[value]"],
|
||||
userId: data["user"],
|
||||
playerId: data.playerId,
|
||||
});
|
||||
|
||||
|
|
@ -169,11 +169,9 @@ function Impersonate() {
|
|||
<h2>Impersonate user</h2>
|
||||
<div>
|
||||
<label>User to log in as</label>
|
||||
<UserCombobox
|
||||
<UserSearch
|
||||
inputName="user"
|
||||
onChange={(selected) =>
|
||||
setUserId(selected?.value ? Number(selected.value) : undefined)
|
||||
}
|
||||
onChange={(newUser) => setUserId(newUser.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
|
|
@ -209,20 +207,16 @@ function MigrateUser() {
|
|||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>Old user</label>
|
||||
<UserCombobox
|
||||
<UserSearch
|
||||
inputName="old-user"
|
||||
onChange={(selected) =>
|
||||
setOldUserId(selected?.value ? Number(selected.value) : undefined)
|
||||
}
|
||||
onChange={(newUser) => setOldUserId(newUser.id)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>New user</label>
|
||||
<UserCombobox
|
||||
<UserSearch
|
||||
inputName="new-user"
|
||||
onChange={(selected) =>
|
||||
setNewUserId(selected?.value ? Number(selected.value) : undefined)
|
||||
}
|
||||
onChange={(newUser) => setNewUserId(newUser.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -249,7 +243,7 @@ function LinkPlayer() {
|
|||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserCombobox inputName="user" />
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Player ID</label>
|
||||
|
|
@ -274,7 +268,7 @@ function GiveArtist() {
|
|||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserCombobox inputName="user" />
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
|
|
@ -295,7 +289,7 @@ function GiveVideoAdder() {
|
|||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserCombobox inputName="user" />
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
|
|
@ -316,7 +310,7 @@ function ForcePatron() {
|
|||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserCombobox inputName="user" />
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Form, useMatches, useOutletContext } from "@remix-run/react";
|
|||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { UserCombobox } from "~/components/Combobox";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { Label } from "~/components/Label";
|
||||
|
|
@ -26,6 +25,7 @@ import {
|
|||
safeJSONParse,
|
||||
} from "~/utils/zod";
|
||||
import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "../$id";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
|
||||
const editBadgeActionSchema = z.union([
|
||||
z.object({
|
||||
|
|
@ -131,15 +131,13 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
|
|||
<h3 className="badges-edit__small-header">Managers</h3>
|
||||
<div className="text-center my-4">
|
||||
<Label className="stack vertical items-center">Add new manager</Label>
|
||||
<UserCombobox
|
||||
<UserSearch
|
||||
className="mx-auto"
|
||||
inputName="new-manager"
|
||||
onChange={(user) => {
|
||||
if (!user) return;
|
||||
|
||||
setManagers([
|
||||
...managers,
|
||||
{ discordFullName: user.label, id: Number(user.value) },
|
||||
{ discordFullName: user.discordName, id: user.id },
|
||||
]);
|
||||
}}
|
||||
userIdsToOmit={userIdsToOmitFromCombobox}
|
||||
|
|
@ -208,17 +206,15 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
|
|||
<h3 className="badges-edit__small-header">Owners</h3>
|
||||
<div className="text-center my-4">
|
||||
<Label className="stack items-center">Add new owner</Label>
|
||||
<UserCombobox
|
||||
<UserSearch
|
||||
className="mx-auto"
|
||||
inputName="new-owner"
|
||||
onChange={(user) => {
|
||||
if (!user) return;
|
||||
|
||||
setOwners([
|
||||
...owners,
|
||||
{
|
||||
discordFullName: user.label,
|
||||
id: Number(user.value),
|
||||
discordFullName: user.discordName,
|
||||
id: user.id,
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import clsx from "clsx";
|
|||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { UserCombobox } from "~/components/Combobox";
|
||||
import { FormErrors } from "~/components/FormErrors";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { CALENDAR_EVENT_RESULT } from "~/constants";
|
||||
import { db } from "~/db";
|
||||
import type { User } from "~/db/types";
|
||||
|
|
@ -412,16 +412,11 @@ function Players({
|
|||
max={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
|
||||
/>
|
||||
) : (
|
||||
<UserCombobox
|
||||
<UserSearch
|
||||
id={formId}
|
||||
inputName="team-player"
|
||||
initialUserId={player.id}
|
||||
onChange={(selected) =>
|
||||
handleInputChange(
|
||||
i,
|
||||
selected?.value ? Number(selected?.value) : NEW_PLAYER.id
|
||||
)
|
||||
}
|
||||
onChange={(newUser) => handleInputChange(i, newUser.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH,
|
||||
PLUS_TIERS,
|
||||
} from "~/constants";
|
||||
import { UserCombobox } from "~/components/Combobox";
|
||||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
|
|
@ -33,6 +32,7 @@ import { FormMessage } from "~/components/FormMessage";
|
|||
import { atOrError } from "~/utils/arrays";
|
||||
import { requireUser, useUser } from "~/modules/auth";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
|
||||
const commentActionSchema = z.object({
|
||||
tier: z.preprocess(
|
||||
|
|
@ -46,7 +46,7 @@ const commentActionSchema = z.object({
|
|||
trimmedString,
|
||||
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 }) => {
|
||||
|
|
@ -55,9 +55,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
schema: commentActionSchema,
|
||||
});
|
||||
|
||||
const suggested = badRequestIfFalsy(
|
||||
db.users.findByIdentifier(data["user[value]"])
|
||||
);
|
||||
const suggested = badRequestIfFalsy(db.users.findByIdentifier(data.userId));
|
||||
|
||||
const user = await requireUser(request);
|
||||
|
||||
|
|
@ -148,7 +146,16 @@ export default function PlusNewSuggestionModalPage() {
|
|||
</div>
|
||||
<div>
|
||||
<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 ? (
|
||||
<FormMessage type="error">{selectedUserErrorMessage}</FormMessage>
|
||||
) : 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 type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { parseSearchParams, type SendouRouteHandle } from "~/utils/remix";
|
||||
import { navIconUrl, userPage, USER_SEARCH_PAGE } from "~/utils/urls";
|
||||
import styles from "~/styles/u.css";
|
||||
import { Input } from "~/components/Input";
|
||||
|
|
@ -12,6 +12,8 @@ import * as React from "react";
|
|||
import { Avatar } from "~/components/Avatar";
|
||||
import { discordFullName } from "~/utils/strings";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { z } from "zod";
|
||||
import { queryToUserIdentifier } from "~/utils/users";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
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) => {
|
||||
const url = new URL(request.url);
|
||||
const input = url.searchParams.get("q");
|
||||
const { q, limit } = parseSearchParams({
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
|
||||
import { json } from "@remix-run/node";
|
||||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { db } from "~/db";
|
||||
import type { UserWithPlusTier } from "~/db/types";
|
||||
import { canAccessLohiEndpoint } from "~/permissions";
|
||||
import { discordFullName } from "~/utils/strings";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
if (!canAccessLohiEndpoint(request)) {
|
||||
|
|
@ -15,20 +12,3 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
|
||||
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);
|
||||
}
|
||||
|
||||
.combobox-username {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article > p {
|
||||
padding-block: var(--s-2-5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export async function selectUser({
|
|||
|
||||
await combobox.clear();
|
||||
await combobox.type(userName);
|
||||
await expect(page.getByTestId("combobox-option-0")).toBeVisible();
|
||||
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_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";
|
||||
|
||||
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 { isAdmin } from "~/permissions";
|
||||
import { isCustomUrl } from "./urls";
|
||||
|
||||
export function isAtLeastFiveDollarTierPatreon(
|
||||
user?: Pick<User, "patronTier" | "id">
|
||||
|
|
@ -8,3 +9,32 @@ export function isAtLeastFiveDollarTierPatreon(
|
|||
|
||||
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