mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Team: Search page
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
app/features/team/queries/allTeams.server.ts
Normal file
44
app/features/team/queries/allTeams.server.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
41
app/features/team/queries/createNewTeam.server.ts
Normal file
41
app/features/team/queries/createNewTeam.server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
);
|
||||
237
app/features/team/routes/t.tsx
Normal file
237
app/features/team/routes/t.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -130,6 +130,10 @@
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-block-start: var(--s-6);
|
||||
}
|
||||
|
||||
.mb-0 {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user