mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Sortable list UI
This commit is contained in:
191
app/components/tournament/AdminTeamControls.tsx
Normal file
191
app/components/tournament/AdminTeamControls.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import classNames from "classnames";
|
||||
import * as React from "react";
|
||||
import { useFetcher, useMatches } from "remix";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Button } from "~/components/Button";
|
||||
import { MyForm } from "~/components/MyForm";
|
||||
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
|
||||
import { checkInHasStarted } from "~/core/tournament/utils";
|
||||
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
|
||||
import type { Unpacked } from "~/utils";
|
||||
|
||||
export function AdminTeamControls() {
|
||||
const [, parentRoute] = useMatches();
|
||||
const { teams, checkInStartTime } =
|
||||
parentRoute.data as FindTournamentByNameForUrlI;
|
||||
const [teamOrder, setTeamOrder] = React.useState(teams.map((t) => t.id));
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const teamsSorted = teams.sort(
|
||||
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert type="info" className="tournament__admin__alert">
|
||||
Drag teams to adjust their seeding
|
||||
</Alert>
|
||||
<ul className="tournament__admin__teams-container">
|
||||
<li className="tournament__admin__teams-list-row">
|
||||
<div className="tournament__admin__teams-container__header">Seed</div>
|
||||
<div className="tournament__admin__teams-container__header">Name</div>
|
||||
<div className="tournament__admin__teams-container__header">
|
||||
{checkInHasStarted(checkInStartTime) ? "" : "Registered at"}
|
||||
</div>
|
||||
<div className="tournament__admin__teams-container__header">
|
||||
Roster size
|
||||
</div>
|
||||
</li>
|
||||
{/* TODO: order by seed */}
|
||||
<DndContext
|
||||
id="team-seed-sorter"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) return;
|
||||
if (active.id !== over.id) {
|
||||
setTeamOrder((teamIds) => {
|
||||
const oldIndex = teamIds.indexOf(active.id);
|
||||
const newIndex = teamIds.indexOf(over.id);
|
||||
|
||||
return arrayMove(teamIds, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={teamOrder}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{teamsSorted.map((team, i) => (
|
||||
<SortableRow key={team.id} team={team} seed={i + 1} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableRow({
|
||||
team,
|
||||
seed,
|
||||
}: {
|
||||
team: Unpacked<FindTournamentByNameForUrlI["teams"]>;
|
||||
seed: number;
|
||||
}) {
|
||||
const [, parentRoute] = useMatches();
|
||||
const { checkInStartTime } = parentRoute.data as FindTournamentByNameForUrlI;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: team.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
className="tournament__admin__teams-list-row sortable"
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div>{seed}</div>
|
||||
<div>{team.name}</div>
|
||||
<div>
|
||||
{!checkInHasStarted(checkInStartTime) ? (
|
||||
<>
|
||||
{new Date(team.createdAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
</>
|
||||
) : team.checkedInTime ? (
|
||||
<CheckOutButton teamId={team.id} />
|
||||
) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? (
|
||||
<CheckInButton teamId={team.id} />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={classNames({
|
||||
tournament__admin__ok:
|
||||
team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
|
||||
tournament__admin__problem:
|
||||
team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
|
||||
})}
|
||||
>
|
||||
{team.members.length}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckOutButton({ teamId }: { teamId: string }) {
|
||||
const fetcher = useFetcher();
|
||||
return (
|
||||
<MyForm
|
||||
action={`/api/tournament/${teamId}/check-out`}
|
||||
className="tournament__action-section__button-container"
|
||||
fetcher={fetcher}
|
||||
>
|
||||
<Button
|
||||
tiny
|
||||
variant="minimal-destructive"
|
||||
loading={fetcher.state !== "idle"}
|
||||
loadingText="Checking out"
|
||||
type="submit"
|
||||
>
|
||||
Check-out
|
||||
</Button>
|
||||
</MyForm>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckInButton({ teamId }: { teamId: string }) {
|
||||
const fetcher = useFetcher();
|
||||
return (
|
||||
<MyForm
|
||||
action={`/api/tournament/${teamId}/check-in`}
|
||||
className="tournament__action-section__button-container"
|
||||
fetcher={fetcher}
|
||||
>
|
||||
<Button
|
||||
tiny
|
||||
variant="minimal"
|
||||
loading={fetcher.state !== "idle"}
|
||||
loadingText="Checking in"
|
||||
type="submit"
|
||||
>
|
||||
Check-in
|
||||
</Button>
|
||||
</MyForm>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +1,20 @@
|
||||
import { useFetcher, useMatches } from "remix";
|
||||
import type { LinksFunction } from "remix";
|
||||
import { Button } from "~/components/Button";
|
||||
import type { FindTournamentByNameForUrlI } from "~/services/tournament";
|
||||
import { AdminTeamControls } from "~/components/tournament/AdminTeamControls";
|
||||
import styles from "~/styles/tournament-admin.css";
|
||||
import * as React from "react";
|
||||
import classNames from "classnames";
|
||||
import { TOURNAMENT_TEAM_ROSTER_MIN_SIZE } from "~/constants";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { MyForm } from "~/components/MyForm";
|
||||
import { checkInHasStarted } from "~/core/tournament/utils";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const [, parentRoute] = useMatches();
|
||||
const { teams, checkInStartTime } =
|
||||
parentRoute.data as FindTournamentByNameForUrlI;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tournament__admin__buttons-container">
|
||||
<Button>Start tournament</Button>
|
||||
<Button variant="outlined">Edit tournament</Button>
|
||||
</div>
|
||||
<Alert type="info" className="tournament__admin__alert">
|
||||
Drag teams to adjust their seeding
|
||||
</Alert>
|
||||
<div className="tournament__admin__teams-container">
|
||||
<div className="tournament__admin__teams-container__header">Seed</div>
|
||||
<div className="tournament__admin__teams-container__header">Name</div>
|
||||
<div className="tournament__admin__teams-container__header">
|
||||
{checkInHasStarted(checkInStartTime) ? "" : "Registered at"}
|
||||
</div>
|
||||
<div className="tournament__admin__teams-container__header">
|
||||
Roster size
|
||||
</div>
|
||||
{/* TODO: order by seed */}
|
||||
{teams.map((team, i) => (
|
||||
<React.Fragment key={team.id}>
|
||||
<div>{i + 1}</div>
|
||||
<div>{team.name}</div>
|
||||
<div>
|
||||
{!checkInHasStarted(checkInStartTime) ? (
|
||||
<>
|
||||
{new Date(team.createdAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
</>
|
||||
) : team.checkedInTime ? (
|
||||
<CheckOutButton teamId={team.id} />
|
||||
) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? (
|
||||
<CheckInButton teamId={team.id} />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={classNames({
|
||||
tournament__admin__ok:
|
||||
team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
|
||||
tournament__admin__problem:
|
||||
team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE,
|
||||
})}
|
||||
>
|
||||
{team.members.length}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<AdminTeamControls />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckOutButton({ teamId }: { teamId: string }) {
|
||||
const fetcher = useFetcher();
|
||||
return (
|
||||
<MyForm
|
||||
action={`/api/tournament/${teamId}/check-out`}
|
||||
className="tournament__action-section__button-container"
|
||||
fetcher={fetcher}
|
||||
>
|
||||
<Button
|
||||
tiny
|
||||
variant="minimal-destructive"
|
||||
loading={fetcher.state !== "idle"}
|
||||
loadingText="Checking out"
|
||||
type="submit"
|
||||
>
|
||||
Check-out
|
||||
</Button>
|
||||
</MyForm>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckInButton({ teamId }: { teamId: string }) {
|
||||
const fetcher = useFetcher();
|
||||
return (
|
||||
<MyForm
|
||||
action={`/api/tournament/${teamId}/check-in`}
|
||||
className="tournament__action-section__button-container"
|
||||
fetcher={fetcher}
|
||||
>
|
||||
<Button
|
||||
tiny
|
||||
variant="minimal"
|
||||
loading={fetcher.state !== "idle"}
|
||||
loadingText="Checking in"
|
||||
type="submit"
|
||||
>
|
||||
Check-in
|
||||
</Button>
|
||||
</MyForm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,16 +11,21 @@
|
||||
}
|
||||
|
||||
/* TODO: overflow-x scroll */
|
||||
.tournament__admin__teams-container {
|
||||
.tournament__admin__teams-list-row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
column-gap: var(--s-1);
|
||||
font-size: var(--fonts-sm);
|
||||
grid-template-columns: 1fr 3fr 3fr 1fr;
|
||||
list-style: none;
|
||||
margin-block-start: var(--s-4);
|
||||
row-gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.tournament__admin__teams-list-row.sortable {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.tournament__admin__teams-container__header {
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
134
package-lock.json
generated
134
package-lock.json
generated
@@ -7,6 +7,8 @@
|
||||
"name": "remix-app-template",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^4.0.3",
|
||||
"@dnd-kit/sortable": "^5.1.0",
|
||||
"@prisma/client": "^3.6.0",
|
||||
"@remix-run/express": "^1.0.6",
|
||||
"@remix-run/react": "^1.0.6",
|
||||
@@ -195,6 +197,75 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz",
|
||||
"integrity": "sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-4.0.3.tgz",
|
||||
"integrity": "sha512-uT1uHZxKx3iEkupmLfknMIvbykMJSetoXXmra6sGGvtWy+OMKrWm3axH2c90+JC/q6qaeKs2znd3Qs8GLnCa5Q==",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.0.0",
|
||||
"@dnd-kit/utilities": "^3.0.1",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core/node_modules/tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-5.1.0.tgz",
|
||||
"integrity": "sha512-CPyiUHbTrSYzhddfgdeoX0ERg/dEyVKIWx9+4O6uqpoppo84SXCBHVFiFBRVpQ9wtpsXs7prtUAnAUTcvFQTZg==",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^4.0.2",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable/node_modules/tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.0.1.tgz",
|
||||
"integrity": "sha512-lAfK9lS5H9I1B/t3iVkQa85Cn+nPRJh3ksPUxAnoxPDqvwcW4tfV0wP++O/dRCP30T76+YF+/XJo7hyOEkzHig==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
},
|
||||
"node_modules/@gar/promisify": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz",
|
||||
@@ -8218,6 +8289,69 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dnd-kit/accessibility": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz",
|
||||
"integrity": "sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow==",
|
||||
"requires": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dnd-kit/core": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-4.0.3.tgz",
|
||||
"integrity": "sha512-uT1uHZxKx3iEkupmLfknMIvbykMJSetoXXmra6sGGvtWy+OMKrWm3axH2c90+JC/q6qaeKs2znd3Qs8GLnCa5Q==",
|
||||
"requires": {
|
||||
"@dnd-kit/accessibility": "^3.0.0",
|
||||
"@dnd-kit/utilities": "^3.0.1",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dnd-kit/sortable": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-5.1.0.tgz",
|
||||
"integrity": "sha512-CPyiUHbTrSYzhddfgdeoX0ERg/dEyVKIWx9+4O6uqpoppo84SXCBHVFiFBRVpQ9wtpsXs7prtUAnAUTcvFQTZg==",
|
||||
"requires": {
|
||||
"@dnd-kit/utilities": "^3.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@dnd-kit/utilities": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.0.1.tgz",
|
||||
"integrity": "sha512-lAfK9lS5H9I1B/t3iVkQa85Cn+nPRJh3ksPUxAnoxPDqvwcW4tfV0wP++O/dRCP30T76+YF+/XJo7hyOEkzHig==",
|
||||
"requires": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"@gar/promisify": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"cy:run": "npx cypress run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^4.0.3",
|
||||
"@dnd-kit/sortable": "^5.1.0",
|
||||
"@prisma/client": "^3.6.0",
|
||||
"@remix-run/express": "^1.0.6",
|
||||
"@remix-run/react": "^1.0.6",
|
||||
|
||||
Reference in New Issue
Block a user