From e1863b328b1ce3623b6c539a09d5ddea6867acf7 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 8 Jan 2023 00:37:32 +0200 Subject: [PATCH] Team: Search page --- app/db/seed.ts | 62 +++++ app/features/team/queries/allTeams.server.ts | 44 ++++ .../team/queries/createNewTeam.server.ts | 41 +++ app/features/team/routes/t.tsx | 237 ++++++++++++++++++ app/features/team/team-schemas.server.ts | 4 + app/features/team/team.css | 44 ++++ app/styles/u.css | 12 +- app/styles/utils.css | 4 + app/utils/arrays.ts | 4 +- public/locales/en/common.json | 1 + public/locales/en/team.json | 3 + remix.config.js | 1 + 12 files changed, 449 insertions(+), 8 deletions(-) create mode 100644 app/features/team/queries/allTeams.server.ts create mode 100644 app/features/team/queries/createNewTeam.server.ts create mode 100644 app/features/team/routes/t.tsx diff --git a/app/db/seed.ts b/app/db/seed.ts index 47c3a0078..05662dd40 100644 --- a/app/db/seed.ts +++ b/app/db/seed.ts @@ -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(); + } + } +} diff --git a/app/features/team/queries/allTeams.server.ts b/app/features/team/queries/allTeams.server.ts new file mode 100644 index 000000000..060fa3af3 --- /dev/null +++ b/app/features/team/queries/allTeams.server.ts @@ -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 & { + avatarSrc?: string; + members: Pick[]; + } +>; + +export function allTeams(): AllTeams { + const rows = stm.all(); + + return rows.map((row) => { + return { + ...row, + members: parseDBArray(row.members), + }; + }); +} diff --git a/app/features/team/queries/createNewTeam.server.ts b/app/features/team/queries/createNewTeam.server.ts new file mode 100644 index 000000000..1b5ffaa50 --- /dev/null +++ b/app/features/team/queries/createNewTeam.server.ts @@ -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, + }); + } +); diff --git a/app/features/team/routes/t.tsx b/app/features/team/routes/t.tsx new file mode 100644 index 000000000..5c257bb21 --- /dev/null +++ b/app/features/team/routes/t.tsx @@ -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(); + + 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 ( +
+ + {user && !data.isMemberOfTeam ? ( + + {t("team:newTeam.button")} + + ) : null} + } + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + placeholder={t("team:teamSearch.placeholder")} + /> +
+ {filteredTeams.map((team) => ( + + {team.avatarSrc ? ( + + ) : ( +
+ {team.name[0]} +
+ )} +
+
{team.name}
+
+ {team.members.length === 1 + ? team.members[0]!.discordName + : joinListToNaturalString( + team.members.map((member) => member.discordName), + "&" + )} +
+
+ + ))} +
+
+ ); +} + +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 ( + +
+

{t("team:newTeam.header")}

+
+ + +
+ +
+ {t("common:actions.create")} + +
+ +
+ ); +} diff --git a/app/features/team/team-schemas.server.ts b/app/features/team/team-schemas.server.ts index 0e1f7d0b6..a98ce8142 100644 --- a/app/features/team/team-schemas.server.ts +++ b/app/features/team/team-schemas.server.ts @@ -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"), diff --git a/app/features/team/team.css b/app/features/team/team.css index 020594b3d..113921702 100644 --- a/app/features/team/team.css +++ b/app/features/team/team.css @@ -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, diff --git a/app/styles/u.css b/app/styles/u.css index 415815217..da0d020b7 100644 --- a/app/styles/u.css +++ b/app/styles/u.css @@ -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%; diff --git a/app/styles/utils.css b/app/styles/utils.css index 944b307e2..9d230d942 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -130,6 +130,10 @@ margin-block-start: var(--s-4); } +.mt-6 { + margin-block-start: var(--s-6); +} + .mb-0 { margin-block-end: 0; } diff --git a/app/utils/arrays.ts b/app/utils/arrays.ts index bfceac172..84f00b8d8 100644 --- a/app/utils/arrays.ts +++ b/app/utils/arrays.ts @@ -27,12 +27,12 @@ function at(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( diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 157c2d8cb..6324cb375 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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...", diff --git a/public/locales/en/team.json b/public/locales/en/team.json index 327dc035d..870501474 100644 --- a/public/locales/en/team.json +++ b/public/locales/en/team.json @@ -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", diff --git a/remix.config.js b/remix.config.js index 2d4b90237..588f4fa59 100644 --- a/remix.config.js +++ b/remix.config.js @@ -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(