Team: Search page

This commit is contained in:
Kalle
2023-01-08 00:37:32 +02:00
parent 2eca0b94c1
commit e1863b328b
12 changed files with 449 additions and 8 deletions

View File

@@ -25,6 +25,7 @@ import allTags from "~/routes/calendar/tags.json";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { UpsertManyPlusVotesArgs } from "./models/plusVotes/queries.server";
import { nanoid } from "nanoid";
import { mySlugify } from "~/utils/urls";
const ADMIN_TEST_AVATAR = "1d1d8488ced4cdf478648592fa871101";
@@ -56,6 +57,7 @@ const basicSeeds = [
adminBuilds,
manySplattershotBuilds,
detailedTeam,
otherTeams,
];
export function seed() {
@@ -922,3 +924,63 @@ function detailedTeam() {
.run();
}
}
function otherTeams() {
const usersInTeam = sql
.prepare(
/*sql */ `select
"userId"
from "AllTeamMember"
`
)
.all()
.map((row) => row.userId);
const userIds = userIdsInRandomOrder().filter(
(u) => !usersInTeam.includes(u)
);
for (let i = 3; i < 100; i++) {
const teamName = `${capitalize(faker.word.adjective())} ${capitalize(
faker.word.noun()
)}`;
const teamCustomUrl = mySlugify(teamName);
sql
.prepare(
/* sql */ `
insert into "AllTeam" ("id", "name", "customUrl", "inviteCode", "twitter", "bio")
values (
${i},
'${teamName}',
'${teamCustomUrl}',
'${nanoid(INVITE_CODE_LENGTH)}',
'${faker.internet.userName()}',
'${faker.lorem.paragraph()}'
)
`
)
.run();
const numMembers = faker.helpers.arrayElement([
1, 2, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 7, 7, 8,
]);
for (let j = 0; j < numMembers; j++) {
const userId = userIds.shift()!;
sql
.prepare(
/*sql*/ `
insert into "AllTeamMember" ("teamId", "userId", "role", "isOwner")
values (
${i},
${userId},
${j === 0 ? "'CAPTAIN'" : "'FRONTLINE'"},
${j === 0 ? 1 : 0}
)
`
)
.run();
}
}
}

View File

@@ -0,0 +1,44 @@
import { sql } from "~/db/sql";
import { parseDBArray } from "~/utils/sql";
import type { Team, UserWithPlusTier } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
"Team"."customUrl",
"Team"."name",
"ia"."url" as "avatarSrc",
json_group_array(
json_object(
'id',
"User"."id",
'discordName',
"User"."discordName",
'plusTier',
"PlusTier"."tier"
)
) as "members"
from "Team"
left join "UserSubmittedImage" as "ia" on "avatarImgId" = "ia"."id"
left join "TeamMember" on "TeamMember"."teamId" = "Team"."id"
left join "User" on "User"."id" = "TeamMember"."userId"
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
group by "Team"."id"
`);
export type AllTeams = Array<
Pick<Team, "customUrl" | "name"> & {
avatarSrc?: string;
members: Pick<UserWithPlusTier, "id" | "plusTier" | "discordName">[];
}
>;
export function allTeams(): AllTeams {
const rows = stm.all();
return rows.map((row) => {
return {
...row,
members: parseDBArray(row.members),
};
});
}

View File

@@ -0,0 +1,41 @@
import { nanoid } from "nanoid";
import { INVITE_CODE_LENGTH } from "~/constants";
import { sql } from "~/db/sql";
import type { Team } from "~/db/types";
const createTeamStm = sql.prepare(/* sql */ `
insert into "AllTeam"
("name", "customUrl", "inviteCode")
values (@name, @customUrl, @inviteCode)
returning *
`);
const createMemberStm = sql.prepare(/* sql */ `
insert into "AllTeamMember"
("teamId", "userId", "isOwner")
values (@teamId, @userId, @isOwner)
`);
export const createNewTeam = sql.transaction(
({
name,
customUrl,
captainId,
}: {
name: string;
customUrl: string;
captainId: number;
}) => {
const team = createTeamStm.get({
name,
customUrl,
inviteCode: nanoid(INVITE_CODE_LENGTH),
}) as Team;
createMemberStm.run({
teamId: team.id,
userId: captainId,
isOwner: 1,
});
}
);

View File

@@ -0,0 +1,237 @@
import type {
ActionFunction,
LinksFunction,
LoaderArgs,
} from "@remix-run/node";
import { redirect } from "@remix-run/node";
import {
Form,
Link,
useLoaderData,
useNavigate,
useSearchParams,
} from "@remix-run/react";
import * as React from "react";
import { Button, LinkButton } from "~/components/Button";
import { Dialog } from "~/components/Dialog";
import { FormErrors } from "~/components/FormErrors";
import { SearchIcon } from "~/components/icons/Search";
import { Input } from "~/components/Input";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useTranslation } from "~/hooks/useTranslation";
import { getUser, requireUser, useUser } from "~/modules/auth";
import { joinListToNaturalString } from "~/utils/arrays";
import type { SendouRouteHandle } from "~/utils/remix";
import { parseRequestFormData, validate } from "~/utils/remix";
import { mySlugify, teamPage, TEAM_SEARCH_PAGE } from "~/utils/urls";
import { allTeams } from "../queries/allTeams.server";
import { createNewTeam } from "../queries/createNewTeam.server";
import { TEAM } from "../team-constants";
import { createTeamSchema } from "../team-schemas.server";
import styles from "../team.css";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: createTeamSchema,
});
const teams = allTeams();
// user creating team isn't in a team yet
validate(
teams.every((team) => team.members.every((member) => member.id !== user.id))
);
// two teams can't have same customUrl
const customUrl = mySlugify(data.name);
if (teams.some((team) => team.customUrl === customUrl)) {
return {
errors: ["forms.errors.duplicateName"],
};
}
createNewTeam({
captainId: user.id,
name: data.name,
customUrl,
});
return redirect(teamPage(customUrl));
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
// breadcrumb: () => ({
// imgPath: navIconUrl("object-damage-calculator"),
// href: OBJECT_DAMAGE_CALCULATOR_URL,
// type: "IMAGE",
// }),
};
export const loader = async ({ request }: LoaderArgs) => {
const user = await getUser(request);
const teams = allTeams().sort((teamA, teamB) => {
// show own team first always
if (user && teamA.members.some((m) => m.id === user.id)) {
return -1;
}
if (user && teamB.members.some((m) => m.id === user.id)) {
return 1;
}
// then full teams
if (teamA.members.length >= 4 && teamB.members.length < 4) {
return -1;
}
if (teamA.members.length < 4 && teamB.members.length >= 4) {
return 1;
}
// and as tiebreaker teams with a higher plus server tier member first
const lowestATeamPlusTier = Math.min(
...teamA.members.map((m) => m.plusTier ?? Infinity)
);
const lowestBTeamPlusTier = Math.min(
...teamB.members.map((m) => m.plusTier ?? Infinity)
);
if (lowestATeamPlusTier > lowestBTeamPlusTier) {
return 1;
}
if (lowestATeamPlusTier < lowestBTeamPlusTier) {
return -1;
}
return 0;
});
return {
teams,
isMemberOfTeam: !user
? false
: teams.some((t) => t.members.some((m) => m.id === user.id)),
};
};
export default function TeamSearchPage() {
const { t } = useTranslation(["team"]);
const user = useUser();
const [inputValue, setInputValue] = React.useState("");
const data = useLoaderData<typeof loader>();
const filteredTeams = data.teams.filter((team) => {
if (!inputValue) return true;
const lowerCaseInput = inputValue.toLowerCase();
if (team.name.toLowerCase().includes(lowerCaseInput)) return true;
if (
team.members.some((m) =>
m.discordName.toLowerCase().includes(lowerCaseInput)
)
) {
return true;
}
return false;
});
return (
<Main className="stack lg">
<NewTeamDialog />
{user && !data.isMemberOfTeam ? (
<LinkButton size="tiny" to="?new=true" className="ml-auto">
{t("team:newTeam.button")}
</LinkButton>
) : null}
<Input
className="team-search__input"
icon={<SearchIcon className="team-search__icon" />}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={t("team:teamSearch.placeholder")}
/>
<div className="mt-6 stack lg">
{filteredTeams.map((team) => (
<Link
key={team.customUrl}
to={teamPage(team.customUrl)}
className="team-search__team"
>
{team.avatarSrc ? (
<img
src={team.avatarSrc}
alt=""
width={64}
height={64}
className="rounded-full"
loading="lazy"
/>
) : (
<div className="team-search__team__avatar-placeholder">
{team.name[0]}
</div>
)}
<div>
<div className="team-search__team__name">{team.name}</div>
<div className="team-search__team__members">
{team.members.length === 1
? team.members[0]!.discordName
: joinListToNaturalString(
team.members.map((member) => member.discordName),
"&"
)}
</div>
</div>
</Link>
))}
</div>
</Main>
);
}
function NewTeamDialog() {
const { t } = useTranslation(["common", "team"]);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const isOpen = searchParams.get("new") === "true";
const close = () => navigate(TEAM_SEARCH_PAGE);
return (
<Dialog isOpen={isOpen} close={close} className="text-center">
<Form method="post" className="stack md">
<h2 className="text-sm">{t("team:newTeam.header")}</h2>
<div className="team-search__form-input-container">
<label htmlFor="name">{t("common:forms.name")}</label>
<input
id="name"
name="name"
minLength={TEAM.NAME_MIN_LENGTH}
maxLength={TEAM.NAME_MAX_LENGTH}
required
/>
</div>
<FormErrors namespace="team" />
<div className="stack horizontal md justify-center mt-4">
<SubmitButton>{t("common:actions.create")}</SubmitButton>
<Button variant="destructive" onClick={close}>
{t("common:actions.cancel")}
</Button>
</div>
</Form>
</Dialog>
);
}

View File

@@ -4,6 +4,10 @@ import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
export const teamParamsSchema = z.object({ customUrl: z.string() });
export const createTeamSchema = z.object({
name: z.string().min(TEAM.NAME_MIN_LENGTH).max(TEAM.NAME_MAX_LENGTH),
});
export const editTeamSchema = z.union([
z.object({
_action: z.literal("DELETE"),

View File

@@ -1,3 +1,47 @@
.team-search__input {
height: 40px !important;
margin: 0 auto;
font-size: var(--fonts-lg);
}
.team-search__icon {
height: 25px;
margin: auto;
margin-right: 15px;
}
.team-search__team {
display: flex;
color: var(--text);
gap: var(--s-3);
align-items: center;
}
.team-search__team__name {
font-size: var(--fonts-xl);
font-weight: var(--bold);
}
.team-search__team__members {
font-size: var(--fonts-xs);
color: var(--text-lighter);
}
.team-search__team__avatar-placeholder {
height: 64px;
min-width: 64px;
display: grid;
place-items: center;
font-size: var(--fonts-xl);
border-radius: 100%;
background-color: var(--theme-transparent);
}
.team-search__form-input-container {
text-align: left;
margin: 0 auto;
}
.team__banner {
background-image: linear-gradient(
to bottom,

View File

@@ -203,6 +203,12 @@
list-style: none;
}
.u-search__input {
height: 40px !important;
margin: 0 auto;
font-size: var(--fonts-lg);
}
.u-search__icon {
height: 25px;
margin: auto;
@@ -215,12 +221,6 @@
text-align: center;
}
.u-search__input {
height: 40px !important;
margin: 0 auto;
font-size: var(--fonts-lg);
}
.u__weapon {
padding: var(--s-2);
border-radius: 100%;

View File

@@ -130,6 +130,10 @@
margin-block-start: var(--s-4);
}
.mt-6 {
margin-block-start: var(--s-6);
}
.mb-0 {
margin-block-end: 0;
}

View File

@@ -27,12 +27,12 @@ function at<T>(arr: T[], n: number) {
return arr[n];
}
export function joinListToNaturalString(arg: string[]) {
export function joinListToNaturalString(arg: string[], lastSeparator = "and") {
const list = [...arg];
const last = list.pop();
const commaJoined = list.join(", ");
return last ? `${commaJoined} and ${last}` : commaJoined;
return last ? `${commaJoined} ${lastSeparator} ${last}` : commaJoined;
}
export function normalizeFormFieldArray(

View File

@@ -43,6 +43,7 @@
"actions.reset": "Reset",
"actions.loadMore": "Load more",
"actions.copyToClipboard": "Copy to clipboard",
"actions.create": "Create",
"actions.close": "Close",
"actions.cancel": "Cancel",
"actions.loading": "Loading...",

View File

@@ -1,4 +1,7 @@
{
"newTeam.button": "New team",
"newTeam.header": "Creating a new team",
"teamSearch.placeholder": "Search for a team or player...",
"actionButtons.leaveTeam": "Leave Team",
"leaveTeam.header": "Are you sure you want to leave {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Leave",

View File

@@ -22,6 +22,7 @@ module.exports = {
route("/to/:id/join", "features/tournament/routes/to.$id.join.tsx");
});
route("/t", "features/team/routes/t.tsx");
route("/t/:customUrl", "features/team/routes/t.$customUrl.tsx");
route("/t/:customUrl/edit", "features/team/routes/t.$customUrl.edit.tsx");
route(