mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-15 07:43:44 -05:00
483 lines
12 KiB
TypeScript
483 lines
12 KiB
TypeScript
import * as React from "react";
|
|
import { SendouButton } from "~/components/elements/Button";
|
|
import {
|
|
SendouChipRadio,
|
|
SendouChipRadioGroup,
|
|
} from "~/components/elements/ChipRadio";
|
|
import { SendouDialog } from "~/components/elements/Dialog";
|
|
import { useTournament } from "~/features/tournament/routes/to.$id";
|
|
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
|
|
import * as CSV from "~/modules/csv";
|
|
import { databaseTimestampToDate } from "~/utils/dates";
|
|
import { teamPage, userPage } from "~/utils/urls";
|
|
import styles from "./ExportDialog.module.css";
|
|
|
|
const BASE_URL = "https://sendou.ink";
|
|
|
|
const EXPORT_FORMATS = ["list", "csv"] as const;
|
|
const EXPORT_STATUSES = ["all", "checkedIn", "notCheckedIn"] as const;
|
|
const EXPORT_SORTS = ["name", "seed", "registration"] as const;
|
|
const EXPORT_ROSTERS = ["full", "captains"] as const;
|
|
|
|
type ExportFormat = (typeof EXPORT_FORMATS)[number];
|
|
type ExportStatus = (typeof EXPORT_STATUSES)[number];
|
|
type ExportSort = (typeof EXPORT_SORTS)[number];
|
|
type ExportRoster = (typeof EXPORT_ROSTERS)[number];
|
|
|
|
const FORMAT_LABELS: Record<ExportFormat, string> = {
|
|
list: "List",
|
|
csv: "CSV",
|
|
};
|
|
const ROSTER_LABELS: Record<ExportRoster, string> = {
|
|
full: "Full roster",
|
|
captains: "Captains only",
|
|
};
|
|
const STATUS_LABELS: Record<ExportStatus, string> = {
|
|
all: "All teams",
|
|
checkedIn: "Checked in only",
|
|
notCheckedIn: "Not checked in",
|
|
};
|
|
const SORT_LABELS: Record<ExportSort, string> = {
|
|
name: "Name",
|
|
seed: "Seed",
|
|
registration: "Registration time",
|
|
};
|
|
|
|
const TEAM_FIELDS = [
|
|
"teamName",
|
|
"seed",
|
|
"registeredAt",
|
|
"checkInStatus",
|
|
"teamPageUrl",
|
|
] as const;
|
|
const MEMBER_FIELDS = [
|
|
"memberUsername",
|
|
"memberInGameName",
|
|
"memberDiscord",
|
|
"memberProfileUrl",
|
|
] as const;
|
|
type ExportField =
|
|
| (typeof TEAM_FIELDS)[number]
|
|
| (typeof MEMBER_FIELDS)[number];
|
|
|
|
const FIELD_LABELS: Record<ExportField, string> = {
|
|
teamName: "Team",
|
|
seed: "Seed",
|
|
registeredAt: "Registration time",
|
|
checkInStatus: "Check-in",
|
|
teamPageUrl: "Team page URL",
|
|
memberUsername: "Username",
|
|
memberInGameName: "In-game name",
|
|
memberDiscord: "Discord mention",
|
|
memberProfileUrl: "Profile URL",
|
|
};
|
|
|
|
const DEFAULT_FIELDS: ExportField[] = [
|
|
"teamName",
|
|
"seed",
|
|
"checkInStatus",
|
|
"memberUsername",
|
|
];
|
|
|
|
export function ExportDialog({ close }: { close: () => void }) {
|
|
const tournament = useTournament();
|
|
|
|
const [format, setFormat] = React.useState<ExportFormat>("list");
|
|
const [status, setStatus] = React.useState<ExportStatus>("all");
|
|
const [bracketIdx, setBracketIdx] = React.useState<number | null>(null);
|
|
const [sort, setSort] = React.useState<ExportSort>("seed");
|
|
const [roster, setRoster] = React.useState<ExportRoster>("full");
|
|
const [fields, setFields] = React.useState<Set<ExportField>>(
|
|
new Set(DEFAULT_FIELDS),
|
|
);
|
|
|
|
const toggleField = (field: ExportField) =>
|
|
setFields((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(field)) {
|
|
next.delete(field);
|
|
} else {
|
|
next.add(field);
|
|
}
|
|
return next;
|
|
});
|
|
|
|
const onDownload = () => {
|
|
const selectedBracket =
|
|
bracketIdx !== null ? tournament.brackets[bracketIdx] : null;
|
|
const bracketRequiresOwnCheckIn = Boolean(selectedBracket?.requiresCheckIn);
|
|
const teams = scopedAndSortedTeams({
|
|
teams: tournament.ctx.teams,
|
|
status,
|
|
sort,
|
|
bracketIdx,
|
|
bracketRequiresOwnCheckIn,
|
|
// A team belongs to a bracket whether or not it has checked in: the
|
|
// checked-in teams populate the matches, the rest are pending check-in.
|
|
// Both are scoped out of the export when they don't belong to the bracket.
|
|
bracketParticipantIds: selectedBracket
|
|
? new Set([
|
|
...selectedBracket.participantTournamentTeamIds,
|
|
...(selectedBracket.teamsPendingCheckIn ?? []),
|
|
])
|
|
: null,
|
|
});
|
|
const content = buildContent({
|
|
teams,
|
|
format,
|
|
fields,
|
|
captainsOnly: roster === "captains",
|
|
bracketIdx,
|
|
bracketRequiresOwnCheckIn,
|
|
checkedInLabel: "Checked in",
|
|
notCheckedInLabel: "Not checked in",
|
|
});
|
|
handleDownload({
|
|
filename: `participants.${format === "csv" ? "csv" : "txt"}`,
|
|
content,
|
|
format,
|
|
});
|
|
close();
|
|
};
|
|
|
|
return (
|
|
<SendouDialog heading="Export participants" onClose={close}>
|
|
<div className="stack md">
|
|
<RadioRow
|
|
label="Format"
|
|
value={format}
|
|
onChange={setFormat}
|
|
options={EXPORT_FORMATS.map((value) => ({
|
|
value,
|
|
label: FORMAT_LABELS[value],
|
|
}))}
|
|
/>
|
|
|
|
<div className="stack sm">
|
|
<div className="text-sm font-bold">Fields</div>
|
|
<div className={styles.fieldGrid}>
|
|
{[...TEAM_FIELDS, ...MEMBER_FIELDS].map((field) => (
|
|
<label key={field} className={styles.fieldLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={fields.has(field)}
|
|
onChange={() => toggleField(field)}
|
|
/>
|
|
{FIELD_LABELS[field]}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<RadioRow
|
|
label="Roster"
|
|
value={roster}
|
|
onChange={setRoster}
|
|
options={EXPORT_ROSTERS.map((value) => ({
|
|
value,
|
|
label: ROSTER_LABELS[value],
|
|
}))}
|
|
/>
|
|
|
|
{tournament.brackets.length > 1 ? (
|
|
<RadioRow
|
|
label="Bracket"
|
|
value={bracketIdx === null ? "all" : String(bracketIdx)}
|
|
onChange={(value) =>
|
|
setBracketIdx(value === "all" ? null : Number(value))
|
|
}
|
|
options={[
|
|
{ value: "all", label: "All brackets" },
|
|
...tournament.brackets.map((bracket, idx) => ({
|
|
value: String(idx),
|
|
label: bracket.name || `#${idx}`,
|
|
})),
|
|
]}
|
|
/>
|
|
) : null}
|
|
|
|
<RadioRow
|
|
label="Status"
|
|
value={status}
|
|
onChange={setStatus}
|
|
options={EXPORT_STATUSES.map((value) => ({
|
|
value,
|
|
label: STATUS_LABELS[value],
|
|
}))}
|
|
/>
|
|
|
|
<RadioRow
|
|
label="Sort by"
|
|
value={sort}
|
|
onChange={setSort}
|
|
options={EXPORT_SORTS.map((value) => ({
|
|
value,
|
|
label: SORT_LABELS[value],
|
|
}))}
|
|
/>
|
|
|
|
<SendouButton onPress={onDownload} className="mx-auto">
|
|
Download
|
|
</SendouButton>
|
|
</div>
|
|
</SendouDialog>
|
|
);
|
|
}
|
|
|
|
function RadioRow<T extends string>({
|
|
label,
|
|
value,
|
|
onChange,
|
|
options,
|
|
}: {
|
|
label: string;
|
|
value: T;
|
|
onChange: (value: T) => void;
|
|
options: ReadonlyArray<{ value: T; label: string }>;
|
|
}) {
|
|
const groupName = React.useId();
|
|
|
|
return (
|
|
<div className="stack sm">
|
|
<div className="text-sm font-bold">{label}</div>
|
|
<SendouChipRadioGroup wrap>
|
|
{options.map((option) => (
|
|
<SendouChipRadio
|
|
key={option.value}
|
|
name={groupName}
|
|
value={option.value}
|
|
checked={value === option.value}
|
|
onChange={(value) => onChange(value as T)}
|
|
>
|
|
{option.label}
|
|
</SendouChipRadio>
|
|
))}
|
|
</SendouChipRadioGroup>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function hasActiveCheckIn(
|
|
team: TournamentDataTeam,
|
|
bracketIdx: number | null,
|
|
bracketRequiresOwnCheckIn: boolean,
|
|
) {
|
|
if (bracketIdx !== null && bracketRequiresOwnCheckIn) {
|
|
return team.checkIns.some(
|
|
(checkIn) => checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut,
|
|
);
|
|
}
|
|
|
|
const eventLevel = team.checkIns.filter(
|
|
(checkIn) => checkIn.bracketIdx === null,
|
|
);
|
|
return (
|
|
eventLevel.some((checkIn) => !checkIn.isCheckOut) &&
|
|
!eventLevel.some((checkIn) => checkIn.isCheckOut)
|
|
);
|
|
}
|
|
|
|
export function scopedAndSortedTeams({
|
|
teams,
|
|
status,
|
|
sort,
|
|
bracketIdx,
|
|
bracketRequiresOwnCheckIn,
|
|
bracketParticipantIds,
|
|
}: {
|
|
teams: TournamentDataTeam[];
|
|
status: ExportStatus;
|
|
sort: ExportSort;
|
|
bracketIdx: number | null;
|
|
bracketRequiresOwnCheckIn: boolean;
|
|
bracketParticipantIds: Set<number> | null;
|
|
}) {
|
|
const filtered = teams.filter((team) => {
|
|
if (bracketParticipantIds && !bracketParticipantIds.has(team.id)) {
|
|
return false;
|
|
}
|
|
switch (status) {
|
|
case "checkedIn":
|
|
return hasActiveCheckIn(team, bracketIdx, bracketRequiresOwnCheckIn);
|
|
case "notCheckedIn":
|
|
return !hasActiveCheckIn(team, bracketIdx, bracketRequiresOwnCheckIn);
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
|
|
return [...filtered].sort((a, b) => {
|
|
switch (sort) {
|
|
case "name":
|
|
return a.name.localeCompare(b.name);
|
|
case "registration":
|
|
return a.createdAt - b.createdAt;
|
|
default: {
|
|
const aSeed = a.seed ?? Number.POSITIVE_INFINITY;
|
|
const bSeed = b.seed ?? Number.POSITIVE_INFINITY;
|
|
if (aSeed !== bSeed) return aSeed - bSeed;
|
|
return a.createdAt - b.createdAt;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function teamFieldValue(
|
|
team: TournamentDataTeam,
|
|
field: (typeof TEAM_FIELDS)[number],
|
|
opts: {
|
|
checkedInLabel: string;
|
|
notCheckedInLabel: string;
|
|
bracketIdx: number | null;
|
|
bracketRequiresOwnCheckIn: boolean;
|
|
},
|
|
) {
|
|
switch (field) {
|
|
case "teamName":
|
|
return team.name;
|
|
case "seed":
|
|
return team.seed != null ? String(team.seed) : "";
|
|
case "registeredAt":
|
|
return databaseTimestampToDate(team.createdAt).toISOString();
|
|
case "checkInStatus":
|
|
return hasActiveCheckIn(
|
|
team,
|
|
opts.bracketIdx,
|
|
opts.bracketRequiresOwnCheckIn,
|
|
)
|
|
? opts.checkedInLabel
|
|
: opts.notCheckedInLabel;
|
|
case "teamPageUrl":
|
|
return team.team?.customUrl
|
|
? `${BASE_URL}${teamPage(team.team.customUrl)}`
|
|
: "";
|
|
}
|
|
}
|
|
|
|
function memberFieldValue(
|
|
member: TournamentDataTeam["members"][number],
|
|
field: (typeof MEMBER_FIELDS)[number],
|
|
) {
|
|
switch (field) {
|
|
case "memberUsername":
|
|
return member.username;
|
|
case "memberInGameName":
|
|
return member.inGameName ?? "";
|
|
case "memberDiscord":
|
|
return member.discordId ? `<@${member.discordId}>` : "";
|
|
case "memberProfileUrl":
|
|
return `${BASE_URL}${userPage(member)}`;
|
|
}
|
|
}
|
|
|
|
function buildContent({
|
|
teams,
|
|
format,
|
|
fields,
|
|
captainsOnly,
|
|
bracketIdx,
|
|
bracketRequiresOwnCheckIn,
|
|
checkedInLabel,
|
|
notCheckedInLabel,
|
|
}: {
|
|
teams: TournamentDataTeam[];
|
|
format: ExportFormat;
|
|
fields: Set<ExportField>;
|
|
captainsOnly: boolean;
|
|
bracketIdx: number | null;
|
|
bracketRequiresOwnCheckIn: boolean;
|
|
checkedInLabel: string;
|
|
notCheckedInLabel: string;
|
|
}) {
|
|
const teamFields = TEAM_FIELDS.filter((field) => fields.has(field));
|
|
const memberFields = MEMBER_FIELDS.filter((field) => fields.has(field));
|
|
const membersOf = (team: TournamentDataTeam) =>
|
|
captainsOnly
|
|
? team.members.filter((member) => member.role === "OWNER")
|
|
: team.members;
|
|
const labelOpts = {
|
|
checkedInLabel,
|
|
notCheckedInLabel,
|
|
bracketIdx,
|
|
bracketRequiresOwnCheckIn,
|
|
};
|
|
|
|
if (format === "csv") {
|
|
const maxRoster = Math.max(
|
|
0,
|
|
...teams.map((team) => membersOf(team).length),
|
|
);
|
|
|
|
const header = [
|
|
...teamFields.map((field) => FIELD_LABELS[field]),
|
|
...Array.from({ length: maxRoster }).flatMap((_, i) =>
|
|
memberFields.map((field) => `Player ${i + 1} ${FIELD_LABELS[field]}`),
|
|
),
|
|
];
|
|
|
|
const rows = teams.map((team) => {
|
|
const teamValues = teamFields.map((field) =>
|
|
teamFieldValue(team, field, labelOpts),
|
|
);
|
|
const teamMembers = membersOf(team);
|
|
const memberValues = Array.from({ length: maxRoster }).flatMap((_, i) => {
|
|
const member = teamMembers[i];
|
|
return memberFields.map((field) =>
|
|
member ? memberFieldValue(member, field) : "",
|
|
);
|
|
});
|
|
return [...teamValues, ...memberValues];
|
|
});
|
|
|
|
return CSV.serialize([header, ...rows]);
|
|
}
|
|
|
|
// list: members grouped under each team (omitted when no member fields chosen,
|
|
// so a team-name-only export is just a plain list of names). Members are only
|
|
// indented under a team header when team fields are present — without them the
|
|
// export is a tight flat list (e.g. captains-only + Discord mention).
|
|
const hasMemberFields = memberFields.length > 0;
|
|
const groupByTeam = teamFields.length > 0;
|
|
|
|
const entries = teams.map((team) => {
|
|
const teamLine = teamFields
|
|
.map((field) => teamFieldValue(team, field, labelOpts))
|
|
.filter(Boolean)
|
|
.join(" - ");
|
|
if (!hasMemberFields) return teamLine;
|
|
const memberLines = membersOf(team).map((member) => {
|
|
const memberLine = memberFields
|
|
.map((field) => memberFieldValue(member, field))
|
|
.filter(Boolean)
|
|
.join(" - ");
|
|
return groupByTeam ? ` ${memberLine}` : memberLine;
|
|
});
|
|
return [teamLine, ...memberLines].filter(Boolean).join("\n");
|
|
});
|
|
|
|
return entries
|
|
.filter(Boolean)
|
|
.join(groupByTeam && hasMemberFields ? "\n\n" : "\n");
|
|
}
|
|
|
|
function handleDownload({
|
|
content,
|
|
filename,
|
|
format,
|
|
}: {
|
|
content: string;
|
|
filename: string;
|
|
format: ExportFormat;
|
|
}) {
|
|
const isCsv = format === "csv";
|
|
const element = document.createElement("a");
|
|
const file = new Blob(isCsv ? [CSV.BOM, content] : [content], {
|
|
type: isCsv ? "text/csv;charset=utf-8" : "text/plain;charset=utf-8",
|
|
});
|
|
element.href = URL.createObjectURL(file);
|
|
element.download = filename;
|
|
document.body.appendChild(element);
|
|
element.click();
|
|
document.body.removeChild(element);
|
|
}
|