mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { scopedAndSortedTeams } from "./ExportDialog";
|
||||
|
||||
function team(
|
||||
id: number,
|
||||
checkIns: TournamentTeamFull["checkIns"],
|
||||
): TournamentTeamFull {
|
||||
return {
|
||||
id,
|
||||
name: `Team ${id}`,
|
||||
seed: id,
|
||||
createdAt: id,
|
||||
checkIns,
|
||||
} as unknown as TournamentTeamFull;
|
||||
}
|
||||
|
||||
function checkIns(
|
||||
rows: Array<{ bracketIdx: number | null; isCheckOut?: number }>,
|
||||
): TournamentTeamFull["checkIns"] {
|
||||
return rows.map((row, i) => ({
|
||||
bracketIdx: row.bracketIdx,
|
||||
checkedInAt: i + 1,
|
||||
isCheckOut: row.isCheckOut ?? 0,
|
||||
})) as unknown as TournamentTeamFull["checkIns"];
|
||||
}
|
||||
|
||||
// Event-level check-in is stored with bracketIdx === null (see CHECK_IN action:
|
||||
// `bracketIdx: bracket.sources ? data.bracketIdx : undefined`). A team checked in
|
||||
// to the tournament therefore has a single null-bracket, non-checkout row.
|
||||
const checkedInAtEventLevel = team(1, checkIns([{ bracketIdx: null }]));
|
||||
|
||||
describe("scopedAndSortedTeams() check-in filtering", () => {
|
||||
// A bracket without its own check-in (the starting bracket / any bracket where
|
||||
// requiresCheckIn is false) is exported against the event-level check-in. This is
|
||||
// the user-reported case: selecting such a bracket previously matched bracketIdx
|
||||
// against the (null) event rows, so "Checked in" was empty and "Not checked in"
|
||||
// listed the teams that were actually checked in.
|
||||
describe("bracket without its own check-in", () => {
|
||||
const bracketParticipantIds = new Set([checkedInAtEventLevel.id]);
|
||||
|
||||
test("includes an event-level checked-in team in 'Checked in only'", () => {
|
||||
const result = scopedAndSortedTeams({
|
||||
teams: [checkedInAtEventLevel],
|
||||
status: "checkedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 0,
|
||||
bracketRequiresOwnCheckIn: false,
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual([checkedInAtEventLevel.id]);
|
||||
});
|
||||
|
||||
test("excludes an event-level checked-in team from 'Not checked in'", () => {
|
||||
const result = scopedAndSortedTeams({
|
||||
teams: [checkedInAtEventLevel],
|
||||
status: "notCheckedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 0,
|
||||
bracketRequiresOwnCheckIn: false,
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// A bracket with its own check-in (requiresCheckIn) is exported against its own
|
||||
// per-bracket rows, so an event-level check-in alone is not enough.
|
||||
describe("bracket with its own check-in", () => {
|
||||
const checkedIntoBracket = team(
|
||||
1,
|
||||
checkIns([{ bracketIdx: null }, { bracketIdx: 2 }]),
|
||||
);
|
||||
const onlyEventLevel = team(2, checkIns([{ bracketIdx: null }]));
|
||||
const bracketParticipantIds = new Set([
|
||||
checkedIntoBracket.id,
|
||||
onlyEventLevel.id,
|
||||
]);
|
||||
|
||||
test("keeps only the team checked into the bracket in 'Checked in only'", () => {
|
||||
const result = scopedAndSortedTeams({
|
||||
teams: [checkedIntoBracket, onlyEventLevel],
|
||||
status: "checkedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 2,
|
||||
bracketRequiresOwnCheckIn: true,
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual([checkedIntoBracket.id]);
|
||||
});
|
||||
|
||||
test("lists a bracket team pending check-in in 'Not checked in'", () => {
|
||||
const result = scopedAndSortedTeams({
|
||||
teams: [checkedIntoBracket, onlyEventLevel],
|
||||
status: "notCheckedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 2,
|
||||
bracketRequiresOwnCheckIn: true,
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual([onlyEventLevel.id]);
|
||||
});
|
||||
|
||||
test("excludes a not-checked-in team that does not participate in the bracket", () => {
|
||||
const notInBracket = team(3, checkIns([]));
|
||||
|
||||
const result = scopedAndSortedTeams({
|
||||
teams: [checkedIntoBracket, onlyEventLevel, notInBracket],
|
||||
status: "notCheckedIn",
|
||||
sort: "seed",
|
||||
bracketIdx: 2,
|
||||
bracketRequiresOwnCheckIn: true,
|
||||
// notInBracket is intentionally absent from the bracket's pool
|
||||
bracketParticipantIds,
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.id)).toEqual([onlyEventLevel.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
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/tournament-context";
|
||||
import type { TournamentTeamFull } 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,
|
||||
teams: allTeams,
|
||||
}: {
|
||||
close: () => void;
|
||||
teams: TournamentTeamFull[];
|
||||
}) {
|
||||
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.bracketsMeta[bracketIdx] : null;
|
||||
const bracketRequiresOwnCheckIn = Boolean(selectedBracket?.requiresCheckIn);
|
||||
const teams = scopedAndSortedTeams({
|
||||
teams: allTeams,
|
||||
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.bracketsMeta.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.bracketsMeta.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: TournamentTeamFull,
|
||||
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: TournamentTeamFull[];
|
||||
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: TournamentTeamFull,
|
||||
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: TournamentTeamFull["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: TournamentTeamFull[];
|
||||
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: TournamentTeamFull) =>
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user