diff --git a/app/components/tournament/AdminTeamControls.tsx b/app/components/tournament/AdminTeamControls.tsx new file mode 100644 index 000000000..ff9eef284 --- /dev/null +++ b/app/components/tournament/AdminTeamControls.tsx @@ -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 ( + <> + + Drag teams to adjust their seeding + + + + ); +} + +function SortableRow({ + team, + seed, +}: { + team: Unpacked; + 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 ( +
  • +
    {seed}
    +
    {team.name}
    +
    + {!checkInHasStarted(checkInStartTime) ? ( + <> + {new Date(team.createdAt).toLocaleString("en-US", { + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + })} + + ) : team.checkedInTime ? ( + + ) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? ( + + ) : null} +
    +
    = TOURNAMENT_TEAM_ROSTER_MIN_SIZE, + tournament__admin__problem: + team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE, + })} + > + {team.members.length} +
    +
  • + ); +} + +function CheckOutButton({ teamId }: { teamId: string }) { + const fetcher = useFetcher(); + return ( + + + + ); +} + +function CheckInButton({ teamId }: { teamId: string }) { + const fetcher = useFetcher(); + return ( + + + + ); +} diff --git a/app/routes/to/$organization.$tournament/admin.tsx b/app/routes/to/$organization.$tournament/admin.tsx index b1f2eed8b..c80b43728 100644 --- a/app/routes/to/$organization.$tournament/admin.tsx +++ b/app/routes/to/$organization.$tournament/admin.tsx @@ -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 ( <>
    - - Drag teams to adjust their seeding - -
    -
    Seed
    -
    Name
    -
    - {checkInHasStarted(checkInStartTime) ? "" : "Registered at"} -
    -
    - Roster size -
    - {/* TODO: order by seed */} - {teams.map((team, i) => ( - -
    {i + 1}
    -
    {team.name}
    -
    - {!checkInHasStarted(checkInStartTime) ? ( - <> - {new Date(team.createdAt).toLocaleString("en-US", { - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - })} - - ) : team.checkedInTime ? ( - - ) : team.members.length >= TOURNAMENT_TEAM_ROSTER_MIN_SIZE ? ( - - ) : null} -
    -
    = TOURNAMENT_TEAM_ROSTER_MIN_SIZE, - tournament__admin__problem: - team.members.length < TOURNAMENT_TEAM_ROSTER_MIN_SIZE, - })} - > - {team.members.length} -
    -
    - ))} -
    + ); } - -function CheckOutButton({ teamId }: { teamId: string }) { - const fetcher = useFetcher(); - return ( - - - - ); -} - -function CheckInButton({ teamId }: { teamId: string }) { - const fetcher = useFetcher(); - return ( - - - - ); -} diff --git a/app/styles/tournament-admin.css b/app/styles/tournament-admin.css index 87596c39e..8ad6ebb72 100644 --- a/app/styles/tournament-admin.css +++ b/app/styles/tournament-admin.css @@ -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); } diff --git a/package-lock.json b/package-lock.json index b1559597c..193d4d518 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b6db94754..6a5fae6a0 100644 --- a/package.json +++ b/package.json @@ -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",