]> git.localhorst.tv Git - alttp.git/commitdiff
vite migration
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Sat, 21 Jun 2025 13:51:17 +0000 (15:51 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Sat, 21 Jun 2025 13:51:17 +0000 (15:51 +0200)
386 files changed:
.env.example
.gitignore
eslint.config.mjs [new file with mode: 0644]
package-lock.json
package.json
resources/js/app/Footer.js [deleted file]
resources/js/app/Footer.jsx [new file with mode: 0644]
resources/js/app/FullLayout.js [deleted file]
resources/js/app/FullLayout.jsx [new file with mode: 0644]
resources/js/app/Header.js [deleted file]
resources/js/app/Header.jsx [new file with mode: 0644]
resources/js/app/LanguageSwitcher.js [deleted file]
resources/js/app/LanguageSwitcher.jsx [new file with mode: 0644]
resources/js/app/PrivacyDialog.js [deleted file]
resources/js/app/PrivacyDialog.jsx [new file with mode: 0644]
resources/js/app/Routes.js [deleted file]
resources/js/app/Routes.jsx [new file with mode: 0644]
resources/js/app/User.js [deleted file]
resources/js/app/User.jsx [new file with mode: 0644]
resources/js/app/index.js [deleted file]
resources/js/app/index.jsx [new file with mode: 0644]
resources/js/bootstrap.js
resources/js/components/alttp-seeds/BaseRomButton.js [deleted file]
resources/js/components/alttp-seeds/BaseRomButton.jsx [new file with mode: 0644]
resources/js/components/alttp-seeds/Seed.js [deleted file]
resources/js/components/alttp-seeds/Seed.jsx [new file with mode: 0644]
resources/js/components/applications/Button.js [deleted file]
resources/js/components/applications/Button.jsx [new file with mode: 0644]
resources/js/components/applications/Dialog.js [deleted file]
resources/js/components/applications/Dialog.jsx [new file with mode: 0644]
resources/js/components/applications/Item.js [deleted file]
resources/js/components/applications/Item.jsx [new file with mode: 0644]
resources/js/components/applications/List.js [deleted file]
resources/js/components/applications/List.jsx [new file with mode: 0644]
resources/js/components/channel/Item.js [deleted file]
resources/js/components/channel/Item.jsx [new file with mode: 0644]
resources/js/components/channel/Link.js [deleted file]
resources/js/components/channel/Link.jsx [new file with mode: 0644]
resources/js/components/channel/List.js [deleted file]
resources/js/components/channel/List.jsx [new file with mode: 0644]
resources/js/components/chat-bot-logs/ChatBotLog.js [deleted file]
resources/js/components/chat-bot-logs/ChatBotLog.jsx [new file with mode: 0644]
resources/js/components/chat-bot-logs/Dialog.js [deleted file]
resources/js/components/chat-bot-logs/Dialog.jsx [new file with mode: 0644]
resources/js/components/chat-bot-logs/Item.js [deleted file]
resources/js/components/chat-bot-logs/Item.jsx [new file with mode: 0644]
resources/js/components/chat-bot-logs/List.js [deleted file]
resources/js/components/chat-bot-logs/List.jsx [new file with mode: 0644]
resources/js/components/chat-logs/Item.js [deleted file]
resources/js/components/chat-logs/Item.jsx [new file with mode: 0644]
resources/js/components/chat-logs/List.js [deleted file]
resources/js/components/chat-logs/List.jsx [new file with mode: 0644]
resources/js/components/common/AspectBox.js [deleted file]
resources/js/components/common/AspectBox.jsx [new file with mode: 0644]
resources/js/components/common/CanonicalLinks.js [deleted file]
resources/js/components/common/CanonicalLinks.jsx [new file with mode: 0644]
resources/js/components/common/ChannelSelect.js [deleted file]
resources/js/components/common/ChannelSelect.jsx [new file with mode: 0644]
resources/js/components/common/DiscordChannelSelect.js [deleted file]
resources/js/components/common/DiscordChannelSelect.jsx [new file with mode: 0644]
resources/js/components/common/DiscordSelect.js [deleted file]
resources/js/components/common/DiscordSelect.jsx [new file with mode: 0644]
resources/js/components/common/ErrorBoundary.js [deleted file]
resources/js/components/common/ErrorBoundary.jsx [new file with mode: 0644]
resources/js/components/common/ErrorMessage.js [deleted file]
resources/js/components/common/ErrorMessage.jsx [new file with mode: 0644]
resources/js/components/common/HTMLInput.js [deleted file]
resources/js/components/common/HTMLInput.jsx [new file with mode: 0644]
resources/js/components/common/Icon.js [deleted file]
resources/js/components/common/Icon.jsx [new file with mode: 0644]
resources/js/components/common/LargeCheck.js [deleted file]
resources/js/components/common/LargeCheck.jsx [new file with mode: 0644]
resources/js/components/common/Loading.js [deleted file]
resources/js/components/common/Loading.jsx [new file with mode: 0644]
resources/js/components/common/PngDialog.js [deleted file]
resources/js/components/common/PngDialog.jsx [new file with mode: 0644]
resources/js/components/common/PngPlayer.js [deleted file]
resources/js/components/common/PngPlayer.jsx [new file with mode: 0644]
resources/js/components/common/RawHTML.js [deleted file]
resources/js/components/common/RawHTML.jsx [new file with mode: 0644]
resources/js/components/common/Slider.js [deleted file]
resources/js/components/common/Slider.jsx [new file with mode: 0644]
resources/js/components/common/Spoiler.js [deleted file]
resources/js/components/common/Spoiler.jsx [new file with mode: 0644]
resources/js/components/common/ToggleSwitch.js [deleted file]
resources/js/components/common/ToggleSwitch.jsx [new file with mode: 0644]
resources/js/components/common/UserSelect.js [deleted file]
resources/js/components/common/UserSelect.jsx [new file with mode: 0644]
resources/js/components/common/ZeldaIcon.js [deleted file]
resources/js/components/common/ZeldaIcon.jsx [new file with mode: 0644]
resources/js/components/discord-bot/Controls.js [deleted file]
resources/js/components/discord-bot/Controls.jsx [new file with mode: 0644]
resources/js/components/discord-guilds/Box.js [deleted file]
resources/js/components/discord-guilds/Box.jsx [new file with mode: 0644]
resources/js/components/discord-guilds/ChannelBox.js [deleted file]
resources/js/components/discord-guilds/ChannelBox.jsx [new file with mode: 0644]
resources/js/components/episodes/ApplyDialog.js [deleted file]
resources/js/components/episodes/ApplyDialog.jsx [new file with mode: 0644]
resources/js/components/episodes/ApplyForm.js [deleted file]
resources/js/components/episodes/ApplyForm.jsx [new file with mode: 0644]
resources/js/components/episodes/Channel.js [deleted file]
resources/js/components/episodes/Channel.jsx [new file with mode: 0644]
resources/js/components/episodes/Channels.js [deleted file]
resources/js/components/episodes/Channels.jsx [new file with mode: 0644]
resources/js/components/episodes/Crew.js [deleted file]
resources/js/components/episodes/Crew.jsx [new file with mode: 0644]
resources/js/components/episodes/CrewManagement.js [deleted file]
resources/js/components/episodes/CrewManagement.jsx [new file with mode: 0644]
resources/js/components/episodes/CrewMember.js [deleted file]
resources/js/components/episodes/CrewMember.jsx [new file with mode: 0644]
resources/js/components/episodes/DialogEpisode.js [deleted file]
resources/js/components/episodes/DialogEpisode.jsx [new file with mode: 0644]
resources/js/components/episodes/Filter.js [deleted file]
resources/js/components/episodes/Filter.jsx [new file with mode: 0644]
resources/js/components/episodes/Item.js [deleted file]
resources/js/components/episodes/Item.jsx [new file with mode: 0644]
resources/js/components/episodes/List.js [deleted file]
resources/js/components/episodes/List.jsx [new file with mode: 0644]
resources/js/components/episodes/MultiLink.js [deleted file]
resources/js/components/episodes/MultiLink.jsx [new file with mode: 0644]
resources/js/components/episodes/Player.js [deleted file]
resources/js/components/episodes/Player.jsx [new file with mode: 0644]
resources/js/components/episodes/Players.js [deleted file]
resources/js/components/episodes/Players.jsx [new file with mode: 0644]
resources/js/components/episodes/RestreamAddForm.js [deleted file]
resources/js/components/episodes/RestreamAddForm.jsx [new file with mode: 0644]
resources/js/components/episodes/RestreamDialog.js [deleted file]
resources/js/components/episodes/RestreamDialog.jsx [new file with mode: 0644]
resources/js/components/episodes/RestreamEditForm.js [deleted file]
resources/js/components/episodes/RestreamEditForm.jsx [new file with mode: 0644]
resources/js/components/events/Detail.js [deleted file]
resources/js/components/events/Detail.jsx [new file with mode: 0644]
resources/js/components/events/Item.js [deleted file]
resources/js/components/events/Item.jsx [new file with mode: 0644]
resources/js/components/events/List.js [deleted file]
resources/js/components/events/List.jsx [new file with mode: 0644]
resources/js/components/map/Buttons.js [deleted file]
resources/js/components/map/Buttons.jsx [new file with mode: 0644]
resources/js/components/map/Item.js [deleted file]
resources/js/components/map/Item.jsx [new file with mode: 0644]
resources/js/components/map/List.js [deleted file]
resources/js/components/map/List.jsx [new file with mode: 0644]
resources/js/components/map/OpenSeadragon.js [deleted file]
resources/js/components/map/OpenSeadragon.jsx [new file with mode: 0644]
resources/js/components/map/Overlay.js [deleted file]
resources/js/components/map/Overlay.jsx [new file with mode: 0644]
resources/js/components/map/Pin.js [deleted file]
resources/js/components/map/Pin.jsx [new file with mode: 0644]
resources/js/components/map/Pins.js [deleted file]
resources/js/components/map/Pins.jsx [new file with mode: 0644]
resources/js/components/map/Popover.js [deleted file]
resources/js/components/map/Popover.jsx [new file with mode: 0644]
resources/js/components/map/UWSuperTiles.js [deleted file]
resources/js/components/map/UWSuperTiles.jsx [new file with mode: 0644]
resources/js/components/map/Viewer.js [deleted file]
resources/js/components/map/Viewer.jsx [new file with mode: 0644]
resources/js/components/participants/List.js [deleted file]
resources/js/components/participants/List.jsx [new file with mode: 0644]
resources/js/components/protocol/Dialog.js [deleted file]
resources/js/components/protocol/Dialog.jsx [new file with mode: 0644]
resources/js/components/protocol/Item.js [deleted file]
resources/js/components/protocol/Item.jsx [new file with mode: 0644]
resources/js/components/protocol/List.js [deleted file]
resources/js/components/protocol/List.jsx [new file with mode: 0644]
resources/js/components/protocol/Protocol.js [deleted file]
resources/js/components/protocol/Protocol.jsx [new file with mode: 0644]
resources/js/components/protocol/RoundProtocol.js [deleted file]
resources/js/components/protocol/RoundProtocol.jsx [new file with mode: 0644]
resources/js/components/results/DetailDialog.js [deleted file]
resources/js/components/results/DetailDialog.jsx [new file with mode: 0644]
resources/js/components/results/Item.js [deleted file]
resources/js/components/results/Item.jsx [new file with mode: 0644]
resources/js/components/results/List.js [deleted file]
resources/js/components/results/List.jsx [new file with mode: 0644]
resources/js/components/results/ReportButton.js [deleted file]
resources/js/components/results/ReportButton.jsx [new file with mode: 0644]
resources/js/components/results/ReportDialog.js [deleted file]
resources/js/components/results/ReportDialog.jsx [new file with mode: 0644]
resources/js/components/results/ReportForm.js [deleted file]
resources/js/components/results/ReportForm.jsx [new file with mode: 0644]
resources/js/components/rounds/DeleteButton.js [deleted file]
resources/js/components/rounds/DeleteButton.jsx [new file with mode: 0644]
resources/js/components/rounds/DeleteDialog.js [deleted file]
resources/js/components/rounds/DeleteDialog.jsx [new file with mode: 0644]
resources/js/components/rounds/EditButton.js [deleted file]
resources/js/components/rounds/EditButton.jsx [new file with mode: 0644]
resources/js/components/rounds/EditDialog.js [deleted file]
resources/js/components/rounds/EditDialog.jsx [new file with mode: 0644]
resources/js/components/rounds/EditForm.js [deleted file]
resources/js/components/rounds/EditForm.jsx [new file with mode: 0644]
resources/js/components/rounds/Item.js [deleted file]
resources/js/components/rounds/Item.jsx [new file with mode: 0644]
resources/js/components/rounds/List.js [deleted file]
resources/js/components/rounds/List.jsx [new file with mode: 0644]
resources/js/components/rounds/LoadMore.js [deleted file]
resources/js/components/rounds/LoadMore.jsx [new file with mode: 0644]
resources/js/components/rounds/LockButton.js [deleted file]
resources/js/components/rounds/LockButton.jsx [new file with mode: 0644]
resources/js/components/rounds/LockDialog.js [deleted file]
resources/js/components/rounds/LockDialog.jsx [new file with mode: 0644]
resources/js/components/rounds/SeedButton.js [deleted file]
resources/js/components/rounds/SeedButton.jsx [new file with mode: 0644]
resources/js/components/rounds/SeedCode.js [deleted file]
resources/js/components/rounds/SeedCode.jsx [new file with mode: 0644]
resources/js/components/rounds/SeedCodeInput.js [deleted file]
resources/js/components/rounds/SeedCodeInput.jsx [new file with mode: 0644]
resources/js/components/rounds/SeedDialog.js [deleted file]
resources/js/components/rounds/SeedDialog.jsx [new file with mode: 0644]
resources/js/components/rounds/SeedForm.js [deleted file]
resources/js/components/rounds/SeedForm.jsx [new file with mode: 0644]
resources/js/components/rounds/SeedRolledBy.js [deleted file]
resources/js/components/rounds/SeedRolledBy.jsx [new file with mode: 0644]
resources/js/components/snes/SettingsDialog.js [deleted file]
resources/js/components/snes/SettingsDialog.jsx [new file with mode: 0644]
resources/js/components/snes/SettingsForm.js [deleted file]
resources/js/components/snes/SettingsForm.jsx [new file with mode: 0644]
resources/js/components/techniques/Detail.js [deleted file]
resources/js/components/techniques/Detail.jsx [new file with mode: 0644]
resources/js/components/techniques/Dialog.js [deleted file]
resources/js/components/techniques/Dialog.jsx [new file with mode: 0644]
resources/js/components/techniques/Form.js [deleted file]
resources/js/components/techniques/Form.jsx [new file with mode: 0644]
resources/js/components/techniques/List.js [deleted file]
resources/js/components/techniques/List.jsx [new file with mode: 0644]
resources/js/components/techniques/Outline.js [deleted file]
resources/js/components/techniques/Outline.jsx [new file with mode: 0644]
resources/js/components/techniques/Overview.js [deleted file]
resources/js/components/techniques/Overview.jsx [new file with mode: 0644]
resources/js/components/techniques/Requirement.js [deleted file]
resources/js/components/techniques/Requirement.jsx [new file with mode: 0644]
resources/js/components/techniques/Requirements.js [deleted file]
resources/js/components/techniques/Requirements.jsx [new file with mode: 0644]
resources/js/components/techniques/Rulesets.js [deleted file]
resources/js/components/techniques/Rulesets.jsx [new file with mode: 0644]
resources/js/components/techniques/TechFilter.js [deleted file]
resources/js/components/techniques/TechFilter.jsx [new file with mode: 0644]
resources/js/components/tournament/ApplyButton.js [deleted file]
resources/js/components/tournament/ApplyButton.jsx [new file with mode: 0644]
resources/js/components/tournament/Detail.js [deleted file]
resources/js/components/tournament/Detail.jsx [new file with mode: 0644]
resources/js/components/tournament/DiscordForm.js [deleted file]
resources/js/components/tournament/DiscordForm.jsx [new file with mode: 0644]
resources/js/components/tournament/ScoreChart.js [deleted file]
resources/js/components/tournament/ScoreChart.jsx [new file with mode: 0644]
resources/js/components/tournament/ScoreChartButton.js [deleted file]
resources/js/components/tournament/ScoreChartButton.jsx [new file with mode: 0644]
resources/js/components/tournament/ScoreChartDialog.js [deleted file]
resources/js/components/tournament/ScoreChartDialog.jsx [new file with mode: 0644]
resources/js/components/tournament/Scoreboard.js [deleted file]
resources/js/components/tournament/Scoreboard.jsx [new file with mode: 0644]
resources/js/components/tournament/SettingsButton.js [deleted file]
resources/js/components/tournament/SettingsButton.jsx [new file with mode: 0644]
resources/js/components/tournament/SettingsDialog.js [deleted file]
resources/js/components/tournament/SettingsDialog.jsx [new file with mode: 0644]
resources/js/components/tracker/AutoTracking.js [deleted file]
resources/js/components/tracker/AutoTracking.jsx [new file with mode: 0644]
resources/js/components/tracker/Canvas.js [deleted file]
resources/js/components/tracker/Canvas.jsx [new file with mode: 0644]
resources/js/components/tracker/ConfigDialog.js [deleted file]
resources/js/components/tracker/ConfigDialog.jsx [new file with mode: 0644]
resources/js/components/tracker/CountDisplay.js [deleted file]
resources/js/components/tracker/CountDisplay.jsx [new file with mode: 0644]
resources/js/components/tracker/Dungeons.js [deleted file]
resources/js/components/tracker/Dungeons.jsx [new file with mode: 0644]
resources/js/components/tracker/Equipment.js [deleted file]
resources/js/components/tracker/Equipment.jsx [new file with mode: 0644]
resources/js/components/tracker/Items.js [deleted file]
resources/js/components/tracker/Items.jsx [new file with mode: 0644]
resources/js/components/tracker/Map/Overworld.js [deleted file]
resources/js/components/tracker/Map/Overworld.jsx [new file with mode: 0644]
resources/js/components/tracker/Map/index.js [deleted file]
resources/js/components/tracker/Map/index.jsx [new file with mode: 0644]
resources/js/components/tracker/ToggleIcon.js [deleted file]
resources/js/components/tracker/ToggleIcon.jsx [new file with mode: 0644]
resources/js/components/tracker/Toolbar.js [deleted file]
resources/js/components/tracker/Toolbar.jsx [new file with mode: 0644]
resources/js/components/tracker/index.js [deleted file]
resources/js/components/tracker/index.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/ChatSettingsForm.js [deleted file]
resources/js/components/twitch-bot/ChatSettingsForm.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/Command.js [deleted file]
resources/js/components/twitch-bot/Command.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/CommandDialog.js [deleted file]
resources/js/components/twitch-bot/CommandDialog.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/CommandForm.js [deleted file]
resources/js/components/twitch-bot/CommandForm.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/Commands.js [deleted file]
resources/js/components/twitch-bot/Commands.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/Controls.js [deleted file]
resources/js/components/twitch-bot/Controls.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingGameAutoTracking.js [deleted file]
resources/js/components/twitch-bot/GuessingGameAutoTracking.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingGameControls.js [deleted file]
resources/js/components/twitch-bot/GuessingGameControls.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingGuess.js [deleted file]
resources/js/components/twitch-bot/GuessingGuess.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingSettingsForm.js [deleted file]
resources/js/components/twitch-bot/GuessingSettingsForm.jsx [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingWinner.js [deleted file]
resources/js/components/twitch-bot/GuessingWinner.jsx [new file with mode: 0644]
resources/js/components/users/Box.js [deleted file]
resources/js/components/users/Box.jsx [new file with mode: 0644]
resources/js/components/users/EditNicknameButton.js [deleted file]
resources/js/components/users/EditNicknameButton.jsx [new file with mode: 0644]
resources/js/components/users/EditNicknameDialog.js [deleted file]
resources/js/components/users/EditNicknameDialog.jsx [new file with mode: 0644]
resources/js/components/users/EditNicknameForm.js [deleted file]
resources/js/components/users/EditNicknameForm.jsx [new file with mode: 0644]
resources/js/components/users/EditStreamLinkButton.js [deleted file]
resources/js/components/users/EditStreamLinkButton.jsx [new file with mode: 0644]
resources/js/components/users/EditStreamLinkDialog.js [deleted file]
resources/js/components/users/EditStreamLinkDialog.jsx [new file with mode: 0644]
resources/js/components/users/EditStreamLinkForm.js [deleted file]
resources/js/components/users/EditStreamLinkForm.jsx [new file with mode: 0644]
resources/js/components/users/Participation.js [deleted file]
resources/js/components/users/Participation.jsx [new file with mode: 0644]
resources/js/components/users/Profile.js [deleted file]
resources/js/components/users/Profile.jsx [new file with mode: 0644]
resources/js/components/users/Records.js [deleted file]
resources/js/components/users/Records.jsx [new file with mode: 0644]
resources/js/components/zootr/MixedPoolsTracker.js [deleted file]
resources/js/components/zootr/MixedPoolsTracker.jsx [new file with mode: 0644]
resources/js/helpers/AlttpBaseRomContext.js [deleted file]
resources/js/helpers/AlttpBaseRomContext.jsx [new file with mode: 0644]
resources/js/helpers/Result.js [deleted file]
resources/js/helpers/Result.jsx [new file with mode: 0644]
resources/js/helpers/nl2br.js [deleted file]
resources/js/helpers/nl2br.jsx [new file with mode: 0644]
resources/js/hooks/snes.js [deleted file]
resources/js/hooks/snes.jsx [new file with mode: 0644]
resources/js/hooks/tracker.js [deleted file]
resources/js/hooks/tracker.jsx [new file with mode: 0644]
resources/js/hooks/user.js [deleted file]
resources/js/hooks/user.jsx [new file with mode: 0644]
resources/js/index.js [deleted file]
resources/js/index.jsx [new file with mode: 0644]
resources/js/pages/AlttpSeed.js [deleted file]
resources/js/pages/AlttpSeed.jsx [new file with mode: 0644]
resources/js/pages/DiscordBot.js [deleted file]
resources/js/pages/DiscordBot.jsx [new file with mode: 0644]
resources/js/pages/DoorsTracker.js [deleted file]
resources/js/pages/DoorsTracker.jsx [new file with mode: 0644]
resources/js/pages/Event.js [deleted file]
resources/js/pages/Event.jsx [new file with mode: 0644]
resources/js/pages/Events.js [deleted file]
resources/js/pages/Events.jsx [new file with mode: 0644]
resources/js/pages/Front.js [deleted file]
resources/js/pages/Front.jsx [new file with mode: 0644]
resources/js/pages/GuessingGameControls.js [deleted file]
resources/js/pages/GuessingGameControls.jsx [new file with mode: 0644]
resources/js/pages/GuessingGameMonitor.js [deleted file]
resources/js/pages/GuessingGameMonitor.jsx [new file with mode: 0644]
resources/js/pages/HorstieLog.js [deleted file]
resources/js/pages/HorstieLog.jsx [new file with mode: 0644]
resources/js/pages/Map.js [deleted file]
resources/js/pages/Map.jsx [new file with mode: 0644]
resources/js/pages/NotFound.js [deleted file]
resources/js/pages/NotFound.jsx [new file with mode: 0644]
resources/js/pages/Schedule.js [deleted file]
resources/js/pages/Schedule.jsx [new file with mode: 0644]
resources/js/pages/Technique.js [deleted file]
resources/js/pages/Technique.jsx [new file with mode: 0644]
resources/js/pages/Techniques.js [deleted file]
resources/js/pages/Techniques.jsx [new file with mode: 0644]
resources/js/pages/Tournament.js [deleted file]
resources/js/pages/Tournament.jsx [new file with mode: 0644]
resources/js/pages/Tracker.js [deleted file]
resources/js/pages/Tracker.jsx [new file with mode: 0644]
resources/js/pages/TwitchBot.js [deleted file]
resources/js/pages/TwitchBot.jsx [new file with mode: 0644]
resources/js/pages/TwitchLegal.js [deleted file]
resources/js/pages/TwitchLegal.jsx [new file with mode: 0644]
resources/js/pages/User.js [deleted file]
resources/js/pages/User.jsx [new file with mode: 0644]
resources/js/pages/ZootrMixedPoolsTracker.js [deleted file]
resources/js/pages/ZootrMixedPoolsTracker.jsx [new file with mode: 0644]
resources/js/setup-jest.js [deleted file]
resources/sass/bootstrap.scss
resources/views/app.blade.php
tests/js/helpers/Result.test.js
tests/js/helpers/User.test.js
tests/js/helpers/logic/inverted.test.js
tests/js/helpers/logic/open.test.js
tests/js/helpers/tracker.test.js
vite.config.js [new file with mode: 0644]
webpack.mix.js [deleted file]

index 956cbc7cae1e9f9010256427e5c6d539896ef61f..1a1fa72972984ec227523661eb3da2c611242475 100644 (file)
@@ -48,8 +48,8 @@ PUSHER_APP_KEY=
 PUSHER_APP_SECRET=
 PUSHER_APP_CLUSTER=mt1
 
-MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
-MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
+VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
+VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
 
 LARASCORD_CLIENT_ID=
 LARASCORD_CLIENT_SECRET=
@@ -57,7 +57,7 @@ LARASCORD_GRANT_TYPE=authorization_code
 LARASCORD_PREFIX=larascord
 LARASCORD_SCOPE=identify
 
-MIX_DISCORD_CLIENT_ID="${LARASCORD_CLIENT_ID}"
+VITE_DISCORD_CLIENT_ID="${LARASCORD_CLIENT_ID}"
 
 DISCORD_BOT_TOKEN=
 DISCORD_BOT_CREATE_COMMANDS=
index ca3555541766c03b02bb7bd6b9e0fe0a2b9fe9a3..f44dc8c11222f1c4d40633bd290d1dd01c3dc5d0 100644 (file)
@@ -1,6 +1,7 @@
 /node_modules
 /public/alttp-seeds
 /public/aos-seeds
+/public/build
 /public/css/app.css
 /public/css/app.css.map
 /public/doortracker
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644 (file)
index 0000000..cd3f9a1
--- /dev/null
@@ -0,0 +1,34 @@
+import js from '@eslint/js';
+import react from 'eslint-plugin-react';
+import globals from 'globals';
+
+export default [
+    js.configs.recommended,
+    react.configs.flat.recommended,
+    {
+        plugins: {
+            react,
+        },
+
+        settings: {
+            react: {
+                version: "detect",
+            },
+        },
+
+        languageOptions: {
+            globals: {
+                ...globals.browser,
+            },
+
+            ecmaVersion: 12,
+            sourceType: "module",
+
+            parserOptions: {
+                ecmaFeatures: {
+                    jsx: true,
+                },
+            },
+        },
+    },
+];
index 736ff49bfdd56c5fa24fb2c0d1fe1ea7ec09110a..d78b943fbcfb53a42c919b46bcd691688a0d0bb9 100644 (file)
                 "@tailwindcss/forms": "^0.5.6",
                 "@testing-library/jest-dom": "^6.4.2",
                 "@testing-library/react": "^14.2.1",
+                "@vitejs/plugin-react": "^4.5.2",
                 "alpinejs": "^3.4.2",
                 "autoprefixer": "^10.4.2",
                 "axios": "^1.5.0",
-                "babel-jest": "^29.7.0",
                 "bootstrap": "^5.1.3",
                 "eslint": "^8.10.0",
                 "eslint-plugin-import": "^2.25.4",
                 "eslint-plugin-react": "^7.29.3",
-                "jest": "^29.7.0",
-                "jest-environment-jsdom": "^29.7.0",
+                "globals": "^16.2.0",
+                "jsdom": "^26.1.0",
                 "laravel-mix": "^6.0.6",
+                "laravel-vite-plugin": "^1.3.0",
                 "lodash": "^4.17.19",
                 "postcss": "^8.4.6",
                 "postcss-import": "^15.1.0",
                 "resolve-url-loader": "^5.0.0",
                 "sass": "^1.32.11",
                 "sass-loader": "^13.3.2",
-                "tailwindcss": "^3.0.18"
+                "tailwindcss": "^3.0.18",
+                "vite": "^6.3.5",
+                "vite-plugin-webpackchunkname": "^1.0.3",
+                "vitest": "^3.2.4"
             }
         },
         "node_modules/@adobe/css-tools": {
                 "node": ">=6.0.0"
             }
         },
+        "node_modules/@asamuzakjp/css-color": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+            "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@csstools/css-calc": "^2.1.3",
+                "@csstools/css-color-parser": "^3.0.9",
+                "@csstools/css-parser-algorithms": "^3.0.4",
+                "@csstools/css-tokenizer": "^3.0.3",
+                "lru-cache": "^10.4.3"
+            }
+        },
+        "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+            "version": "10.4.3",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+            "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+            "dev": true,
+            "license": "ISC"
+        },
         "node_modules/@babel/code-frame": {
-            "version": "7.24.7",
-            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
-            "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+            "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/highlight": "^7.24.7",
-                "picocolors": "^1.0.0"
+                "@babel/helper-validator-identifier": "^7.27.1",
+                "js-tokens": "^4.0.0",
+                "picocolors": "^1.1.1"
             },
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/compat-data": {
-            "version": "7.25.4",
-            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz",
-            "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==",
+            "version": "7.27.5",
+            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz",
+            "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/core": {
-            "version": "7.25.2",
-            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
-            "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
+            "version": "7.27.4",
+            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
+            "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "@ampproject/remapping": "^2.2.0",
-                "@babel/code-frame": "^7.24.7",
-                "@babel/generator": "^7.25.0",
-                "@babel/helper-compilation-targets": "^7.25.2",
-                "@babel/helper-module-transforms": "^7.25.2",
-                "@babel/helpers": "^7.25.0",
-                "@babel/parser": "^7.25.0",
-                "@babel/template": "^7.25.0",
-                "@babel/traverse": "^7.25.2",
-                "@babel/types": "^7.25.2",
+                "@babel/code-frame": "^7.27.1",
+                "@babel/generator": "^7.27.3",
+                "@babel/helper-compilation-targets": "^7.27.2",
+                "@babel/helper-module-transforms": "^7.27.3",
+                "@babel/helpers": "^7.27.4",
+                "@babel/parser": "^7.27.4",
+                "@babel/template": "^7.27.2",
+                "@babel/traverse": "^7.27.4",
+                "@babel/types": "^7.27.3",
                 "convert-source-map": "^2.0.0",
                 "debug": "^4.1.0",
                 "gensync": "^1.0.0-beta.2",
             }
         },
         "node_modules/@babel/generator": {
-            "version": "7.25.6",
-            "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz",
-            "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==",
+            "version": "7.27.5",
+            "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz",
+            "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/types": "^7.25.6",
+                "@babel/parser": "^7.27.5",
+                "@babel/types": "^7.27.3",
                 "@jridgewell/gen-mapping": "^0.3.5",
                 "@jridgewell/trace-mapping": "^0.3.25",
-                "jsesc": "^2.5.1"
+                "jsesc": "^3.0.2"
             },
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/helper-compilation-targets": {
-            "version": "7.25.2",
-            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
-            "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
+            "version": "7.27.2",
+            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+            "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/compat-data": "^7.25.2",
-                "@babel/helper-validator-option": "^7.24.8",
-                "browserslist": "^4.23.1",
+                "@babel/compat-data": "^7.27.2",
+                "@babel/helper-validator-option": "^7.27.1",
+                "browserslist": "^4.24.0",
                 "lru-cache": "^5.1.1",
                 "semver": "^6.3.1"
             },
             }
         },
         "node_modules/@babel/helper-module-imports": {
-            "version": "7.24.7",
-            "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz",
-            "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==",
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+            "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/traverse": "^7.24.7",
-                "@babel/types": "^7.24.7"
+                "@babel/traverse": "^7.27.1",
+                "@babel/types": "^7.27.1"
             },
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/helper-module-transforms": {
-            "version": "7.25.2",
-            "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz",
-            "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==",
+            "version": "7.27.3",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
+            "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/helper-module-imports": "^7.24.7",
-                "@babel/helper-simple-access": "^7.24.7",
-                "@babel/helper-validator-identifier": "^7.24.7",
-                "@babel/traverse": "^7.25.2"
+                "@babel/helper-module-imports": "^7.27.1",
+                "@babel/helper-validator-identifier": "^7.27.1",
+                "@babel/traverse": "^7.27.3"
             },
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/helper-plugin-utils": {
-            "version": "7.24.8",
-            "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz",
-            "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==",
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+            "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=6.9.0"
             }
             }
         },
         "node_modules/@babel/helper-string-parser": {
-            "version": "7.24.8",
-            "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
-            "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+            "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/helper-validator-identifier": {
-            "version": "7.24.7",
-            "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
-            "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+            "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/helper-validator-option": {
-            "version": "7.24.8",
-            "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz",
-            "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==",
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+            "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=6.9.0"
             }
             }
         },
         "node_modules/@babel/helpers": {
-            "version": "7.25.6",
-            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
-            "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
-            "dev": true,
-            "dependencies": {
-                "@babel/template": "^7.25.0",
-                "@babel/types": "^7.25.6"
-            },
-            "engines": {
-                "node": ">=6.9.0"
-            }
-        },
-        "node_modules/@babel/highlight": {
-            "version": "7.24.7",
-            "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
-            "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
+            "version": "7.27.6",
+            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
+            "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/helper-validator-identifier": "^7.24.7",
-                "chalk": "^2.4.2",
-                "js-tokens": "^4.0.0",
-                "picocolors": "^1.0.0"
+                "@babel/template": "^7.27.2",
+                "@babel/types": "^7.27.6"
             },
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/parser": {
-            "version": "7.25.6",
-            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz",
-            "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==",
+            "version": "7.27.5",
+            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz",
+            "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/types": "^7.25.6"
+                "@babel/types": "^7.27.3"
             },
             "bin": {
                 "parser": "bin/babel-parser.js"
                 "@babel/core": "^7.0.0-0"
             }
         },
-        "node_modules/@babel/plugin-syntax-bigint": {
-            "version": "7.8.3",
-            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz",
-            "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
-            "dev": true,
-            "dependencies": {
-                "@babel/helper-plugin-utils": "^7.8.0"
-            },
-            "peerDependencies": {
-                "@babel/core": "^7.0.0-0"
-            }
-        },
         "node_modules/@babel/plugin-syntax-class-properties": {
             "version": "7.12.13",
             "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
                 "@babel/core": "^7.0.0-0"
             }
         },
-        "node_modules/@babel/plugin-syntax-typescript": {
-            "version": "7.25.4",
-            "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz",
-            "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==",
-            "dev": true,
-            "dependencies": {
-                "@babel/helper-plugin-utils": "^7.24.8"
-            },
-            "engines": {
-                "node": ">=6.9.0"
-            },
-            "peerDependencies": {
-                "@babel/core": "^7.0.0-0"
-            }
-        },
         "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
             "version": "7.18.6",
             "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
                 "@babel/core": "^7.0.0-0"
             }
         },
+        "node_modules/@babel/plugin-transform-classes/node_modules/globals": {
+            "version": "11.12.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+            "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=4"
+            }
+        },
         "node_modules/@babel/plugin-transform-computed-properties": {
             "version": "7.24.7",
             "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz",
                 "@babel/core": "^7.0.0-0"
             }
         },
+        "node_modules/@babel/plugin-transform-react-jsx-self": {
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+            "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.27.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-react-jsx-source": {
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+            "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.27.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
         "node_modules/@babel/plugin-transform-react-pure-annotations": {
             "version": "7.24.7",
             "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.7.tgz",
             }
         },
         "node_modules/@babel/template": {
-            "version": "7.25.0",
-            "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz",
-            "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==",
+            "version": "7.27.2",
+            "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+            "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/code-frame": "^7.24.7",
-                "@babel/parser": "^7.25.0",
-                "@babel/types": "^7.25.0"
+                "@babel/code-frame": "^7.27.1",
+                "@babel/parser": "^7.27.2",
+                "@babel/types": "^7.27.1"
             },
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/traverse": {
-            "version": "7.25.6",
-            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz",
-            "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==",
+            "version": "7.27.4",
+            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz",
+            "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/code-frame": "^7.24.7",
-                "@babel/generator": "^7.25.6",
-                "@babel/parser": "^7.25.6",
-                "@babel/template": "^7.25.0",
-                "@babel/types": "^7.25.6",
+                "@babel/code-frame": "^7.27.1",
+                "@babel/generator": "^7.27.3",
+                "@babel/parser": "^7.27.4",
+                "@babel/template": "^7.27.2",
+                "@babel/types": "^7.27.3",
                 "debug": "^4.3.1",
                 "globals": "^11.1.0"
             },
                 "node": ">=6.9.0"
             }
         },
+        "node_modules/@babel/traverse/node_modules/globals": {
+            "version": "11.12.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+            "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=4"
+            }
+        },
         "node_modules/@babel/types": {
-            "version": "7.25.6",
-            "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz",
-            "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==",
+            "version": "7.27.6",
+            "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz",
+            "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/helper-string-parser": "^7.24.8",
-                "@babel/helper-validator-identifier": "^7.24.7",
-                "to-fast-properties": "^2.0.0"
+                "@babel/helper-string-parser": "^7.27.1",
+                "@babel/helper-validator-identifier": "^7.27.1"
             },
             "engines": {
                 "node": ">=6.9.0"
             }
         },
-        "node_modules/@bcoe/v8-coverage": {
-            "version": "0.2.3",
-            "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
-            "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
-            "dev": true
-        },
         "node_modules/@codemirror/autocomplete": {
             "version": "6.18.0",
             "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.0.tgz",
                 "node": ">=0.1.90"
             }
         },
-        "node_modules/@discoveryjs/json-ext": {
-            "version": "0.5.7",
-            "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
-            "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
+        "node_modules/@csstools/color-helpers": {
+            "version": "5.0.2",
+            "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz",
+            "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==",
             "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "license": "MIT-0",
             "engines": {
-                "node": ">=10.0.0"
+                "node": ">=18"
             }
         },
-        "node_modules/@eslint-community/eslint-utils": {
-            "version": "4.4.0",
-            "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
-            "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+        "node_modules/@csstools/css-calc": {
+            "version": "2.1.4",
+            "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+            "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
             "dev": true,
-            "dependencies": {
-                "eslint-visitor-keys": "^3.3.0"
-            },
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "license": "MIT",
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": ">=18"
             },
             "peerDependencies": {
-                "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+                "@csstools/css-parser-algorithms": "^3.0.5",
+                "@csstools/css-tokenizer": "^3.0.4"
             }
         },
-        "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
-            "version": "3.4.3",
-            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
-            "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+        "node_modules/@csstools/css-color-parser": {
+            "version": "3.0.10",
+            "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz",
+            "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==",
             "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "license": "MIT",
+            "dependencies": {
+                "@csstools/color-helpers": "^5.0.2",
+                "@csstools/css-calc": "^2.1.4"
+            },
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": ">=18"
             },
-            "funding": {
-                "url": "https://opencollective.com/eslint"
+            "peerDependencies": {
+                "@csstools/css-parser-algorithms": "^3.0.5",
+                "@csstools/css-tokenizer": "^3.0.4"
             }
         },
-        "node_modules/@eslint-community/regexpp": {
-            "version": "4.11.0",
-            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz",
-            "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==",
+        "node_modules/@csstools/css-parser-algorithms": {
+            "version": "3.0.5",
+            "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+            "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
             "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "license": "MIT",
             "engines": {
-                "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+                "node": ">=18"
+            },
+            "peerDependencies": {
+                "@csstools/css-tokenizer": "^3.0.4"
             }
         },
-        "node_modules/@eslint/eslintrc": {
-            "version": "2.1.4",
-            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
-            "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+        "node_modules/@csstools/css-tokenizer": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+            "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
             "dev": true,
-            "dependencies": {
-                "ajv": "^6.12.4",
-                "debug": "^4.3.2",
-                "espree": "^9.6.0",
-                "globals": "^13.19.0",
-                "ignore": "^5.2.0",
-                "import-fresh": "^3.2.1",
-                "js-yaml": "^4.1.0",
-                "minimatch": "^3.1.2",
-                "strip-json-comments": "^3.1.1"
-            },
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/csstools"
+                },
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/csstools"
+                }
+            ],
+            "license": "MIT",
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
-            },
-            "funding": {
-                "url": "https://opencollective.com/eslint"
+                "node": ">=18"
             }
         },
-        "node_modules/@eslint/eslintrc/node_modules/argparse": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
-            "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
-            "dev": true
-        },
-        "node_modules/@eslint/eslintrc/node_modules/globals": {
-            "version": "13.24.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
-            "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+        "node_modules/@discoveryjs/json-ext": {
+            "version": "0.5.7",
+            "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
+            "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==",
             "dev": true,
-            "dependencies": {
-                "type-fest": "^0.20.2"
-            },
             "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
+                "node": ">=10.0.0"
             }
         },
-        "node_modules/@eslint/eslintrc/node_modules/js-yaml": {
-            "version": "4.1.0",
-            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
-            "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+        "node_modules/@esbuild/aix-ppc64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
+            "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
+            "cpu": [
+                "ppc64"
+            ],
             "dev": true,
-            "dependencies": {
-                "argparse": "^2.0.1"
-            },
-            "bin": {
-                "js-yaml": "bin/js-yaml.js"
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "aix"
+            ],
+            "engines": {
+                "node": ">=18"
             }
         },
-        "node_modules/@eslint/eslintrc/node_modules/type-fest": {
-            "version": "0.20.2",
-            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
-            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+        "node_modules/@esbuild/android-arm": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
+            "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
+            "cpu": [
+                "arm"
+            ],
             "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
             "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
+                "node": ">=18"
             }
         },
-        "node_modules/@eslint/js": {
-            "version": "8.57.0",
-            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
-            "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+        "node_modules/@esbuild/android-arm64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
+            "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
+            "cpu": [
+                "arm64"
+            ],
             "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
             "engines": {
-                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+                "node": ">=18"
             }
         },
-        "node_modules/@fortawesome/fontawesome-common-types": {
-            "version": "6.6.0",
-            "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
-            "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
+        "node_modules/@esbuild/android-x64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
+            "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
             "engines": {
-                "node": ">=6"
+                "node": ">=18"
             }
         },
-        "node_modules/@fortawesome/fontawesome-free": {
-            "version": "6.6.0",
-            "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.6.0.tgz",
-            "integrity": "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow==",
+        "node_modules/@esbuild/darwin-arm64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
+            "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
             "engines": {
-                "node": ">=6"
+                "node": ">=18"
             }
         },
-        "node_modules/@fortawesome/fontawesome-svg-core": {
-            "version": "6.6.0",
-            "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
-            "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
-            "dependencies": {
-                "@fortawesome/fontawesome-common-types": "6.6.0"
-            },
+        "node_modules/@esbuild/darwin-x64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
+            "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
             "engines": {
-                "node": ">=6"
+                "node": ">=18"
             }
         },
-        "node_modules/@fortawesome/free-brands-svg-icons": {
-            "version": "6.6.0",
-            "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz",
-            "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
-            "dependencies": {
-                "@fortawesome/fontawesome-common-types": "6.6.0"
-            },
+        "node_modules/@esbuild/freebsd-arm64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
+            "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
             "engines": {
-                "node": ">=6"
+                "node": ">=18"
             }
         },
-        "node_modules/@fortawesome/free-solid-svg-icons": {
-            "version": "6.6.0",
-            "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
-            "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
-            "dependencies": {
-                "@fortawesome/fontawesome-common-types": "6.6.0"
-            },
+        "node_modules/@esbuild/freebsd-x64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
+            "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
             "engines": {
-                "node": ">=6"
+                "node": ">=18"
             }
         },
-        "node_modules/@fortawesome/react-fontawesome": {
-            "version": "0.2.2",
-            "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
-            "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
-            "dependencies": {
-                "prop-types": "^15.8.1"
-            },
-            "peerDependencies": {
-                "@fortawesome/fontawesome-svg-core": "~1 || ~6",
-                "react": ">=16.3"
+        "node_modules/@esbuild/linux-arm": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
+            "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
+            "cpu": [
+                "arm"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
             }
         },
-        "node_modules/@humanwhocodes/config-array": {
-            "version": "0.11.14",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
-            "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
-            "deprecated": "Use @eslint/config-array instead",
+        "node_modules/@esbuild/linux-arm64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
+            "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
+            "cpu": [
+                "arm64"
+            ],
             "dev": true,
-            "dependencies": {
-                "@humanwhocodes/object-schema": "^2.0.2",
-                "debug": "^4.3.1",
-                "minimatch": "^3.0.5"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
             "engines": {
-                "node": ">=10.10.0"
+                "node": ">=18"
             }
         },
-        "node_modules/@humanwhocodes/module-importer": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
-            "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+        "node_modules/@esbuild/linux-ia32": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
+            "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
+            "cpu": [
+                "ia32"
+            ],
             "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
             "engines": {
-                "node": ">=12.22"
-            },
-            "funding": {
-                "type": "github",
-                "url": "https://github.com/sponsors/nzakas"
+                "node": ">=18"
             }
         },
-        "node_modules/@humanwhocodes/object-schema": {
-            "version": "2.0.3",
-            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
-            "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
-            "deprecated": "Use @eslint/object-schema instead",
-            "dev": true
-        },
-        "node_modules/@isaacs/cliui": {
-            "version": "8.0.2",
-            "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
-            "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+        "node_modules/@esbuild/linux-loong64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
+            "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
+            "cpu": [
+                "loong64"
+            ],
             "dev": true,
-            "dependencies": {
-                "string-width": "^5.1.2",
-                "string-width-cjs": "npm:string-width@^4.2.0",
-                "strip-ansi": "^7.0.1",
-                "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
-                "wrap-ansi": "^8.1.0",
-                "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
             "engines": {
-                "node": ">=12"
+                "node": ">=18"
             }
         },
-        "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
-            "version": "6.0.1",
-            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
-            "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+        "node_modules/@esbuild/linux-mips64el": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
+            "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
+            "cpu": [
+                "mips64el"
+            ],
             "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
             "engines": {
-                "node": ">=12"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+                "node": ">=18"
             }
         },
-        "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
-            "version": "6.2.1",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
-            "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+        "node_modules/@esbuild/linux-ppc64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
+            "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
+            "cpu": [
+                "ppc64"
+            ],
             "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
             "engines": {
-                "node": ">=12"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "node": ">=18"
             }
         },
-        "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
-            "version": "9.2.2",
-            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
-            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
-            "dev": true
+        "node_modules/@esbuild/linux-riscv64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
+            "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
+            "cpu": [
+                "riscv64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
         },
-        "node_modules/@isaacs/cliui/node_modules/string-width": {
-            "version": "5.1.2",
-            "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
-            "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+        "node_modules/@esbuild/linux-s390x": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
+            "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
+            "cpu": [
+                "s390x"
+            ],
             "dev": true,
-            "dependencies": {
-                "eastasianwidth": "^0.2.0",
-                "emoji-regex": "^9.2.2",
-                "strip-ansi": "^7.0.1"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
             "engines": {
-                "node": ">=12"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
+                "node": ">=18"
             }
         },
-        "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
-            "version": "7.1.0",
-            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
-            "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+        "node_modules/@esbuild/linux-x64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
+            "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
+            "cpu": [
+                "x64"
+            ],
             "dev": true,
-            "dependencies": {
-                "ansi-regex": "^6.0.1"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
             "engines": {
-                "node": ">=12"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+                "node": ">=18"
             }
         },
-        "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
-            "version": "8.1.0",
-            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
-            "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+        "node_modules/@esbuild/netbsd-arm64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
+            "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
+            "cpu": [
+                "arm64"
+            ],
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^6.1.0",
-                "string-width": "^5.0.1",
-                "strip-ansi": "^7.0.1"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "netbsd"
+            ],
             "engines": {
-                "node": ">=12"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+                "node": ">=18"
             }
         },
-        "node_modules/@istanbuljs/load-nyc-config": {
-            "version": "1.1.0",
-            "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
-            "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+        "node_modules/@esbuild/netbsd-x64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
+            "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
+            "cpu": [
+                "x64"
+            ],
             "dev": true,
-            "dependencies": {
-                "camelcase": "^5.3.1",
-                "find-up": "^4.1.0",
-                "get-package-type": "^0.1.0",
-                "js-yaml": "^3.13.1",
-                "resolve-from": "^5.0.0"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "netbsd"
+            ],
             "engines": {
-                "node": ">=8"
+                "node": ">=18"
             }
         },
-        "node_modules/@istanbuljs/schema": {
-            "version": "0.1.3",
-            "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
-            "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+        "node_modules/@esbuild/openbsd-arm64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
+            "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
+            "cpu": [
+                "arm64"
+            ],
             "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openbsd"
+            ],
             "engines": {
-                "node": ">=8"
+                "node": ">=18"
             }
         },
-        "node_modules/@jest/console": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
-            "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
+        "node_modules/@esbuild/openbsd-x64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
+            "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
+            "cpu": [
+                "x64"
+            ],
             "dev": true,
-            "dependencies": {
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "chalk": "^4.0.0",
-                "jest-message-util": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "slash": "^3.0.0"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openbsd"
+            ],
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=18"
             }
         },
-        "node_modules/@jest/console/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/@esbuild/sunos-x64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
+            "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
+            "cpu": [
+                "x64"
+            ],
             "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "sunos"
+            ],
             "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "node": ">=18"
             }
         },
-        "node_modules/@jest/console/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/@esbuild/win32-arm64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
+            "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
+            "cpu": [
+                "arm64"
+            ],
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
-            }
-        },
-        "node_modules/@jest/console/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">=18"
             }
         },
-        "node_modules/@jest/console/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/@jest/console/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/@esbuild/win32-ia32": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
+            "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
+            "cpu": [
+                "ia32"
+            ],
             "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
             "engines": {
-                "node": ">=8"
+                "node": ">=18"
             }
         },
-        "node_modules/@jest/console/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/@esbuild/win32-x64": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
+            "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
+            "cpu": [
+                "x64"
+            ],
             "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
             "engines": {
-                "node": ">=8"
+                "node": ">=18"
             }
         },
-        "node_modules/@jest/core": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
-            "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
+        "node_modules/@eslint-community/eslint-utils": {
+            "version": "4.4.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+            "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
             "dev": true,
             "dependencies": {
-                "@jest/console": "^29.7.0",
-                "@jest/reporters": "^29.7.0",
-                "@jest/test-result": "^29.7.0",
-                "@jest/transform": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "ansi-escapes": "^4.2.1",
-                "chalk": "^4.0.0",
-                "ci-info": "^3.2.0",
-                "exit": "^0.1.2",
-                "graceful-fs": "^4.2.9",
-                "jest-changed-files": "^29.7.0",
-                "jest-config": "^29.7.0",
-                "jest-haste-map": "^29.7.0",
-                "jest-message-util": "^29.7.0",
-                "jest-regex-util": "^29.6.3",
-                "jest-resolve": "^29.7.0",
-                "jest-resolve-dependencies": "^29.7.0",
-                "jest-runner": "^29.7.0",
-                "jest-runtime": "^29.7.0",
-                "jest-snapshot": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "jest-validate": "^29.7.0",
-                "jest-watcher": "^29.7.0",
-                "micromatch": "^4.0.4",
-                "pretty-format": "^29.7.0",
-                "slash": "^3.0.0",
-                "strip-ansi": "^6.0.0"
+                "eslint-visitor-keys": "^3.3.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             },
             "peerDependencies": {
-                "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-            },
-            "peerDependenciesMeta": {
-                "node-notifier": {
-                    "optional": true
-                }
+                "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
             }
         },
-        "node_modules/@jest/core/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+            "version": "3.4.3",
+            "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+            "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
             "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
             "engines": {
-                "node": ">=8"
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://opencollective.com/eslint"
             }
         },
-        "node_modules/@jest/core/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/@eslint-community/regexpp": {
+            "version": "4.11.0",
+            "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz",
+            "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==",
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
             "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
             }
         },
-        "node_modules/@jest/core/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/@eslint/eslintrc": {
+            "version": "2.1.4",
+            "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+            "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "ajv": "^6.12.4",
+                "debug": "^4.3.2",
+                "espree": "^9.6.0",
+                "globals": "^13.19.0",
+                "ignore": "^5.2.0",
+                "import-fresh": "^3.2.1",
+                "js-yaml": "^4.1.0",
+                "minimatch": "^3.1.2",
+                "strip-json-comments": "^3.1.1"
             },
             "engines": {
-                "node": ">=7.0.0"
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/eslint"
             }
         },
-        "node_modules/@jest/core/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+        "node_modules/@eslint/eslintrc/node_modules/argparse": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+            "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
             "dev": true
         },
-        "node_modules/@jest/core/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/@eslint/eslintrc/node_modules/globals": {
+            "version": "13.24.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+            "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
             "dev": true,
+            "dependencies": {
+                "type-fest": "^0.20.2"
+            },
             "engines": {
                 "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/@jest/core/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+        "node_modules/@eslint/eslintrc/node_modules/js-yaml": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+            "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
             "dev": true,
             "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
+                "argparse": "^2.0.1"
             },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+            "bin": {
+                "js-yaml": "bin/js-yaml.js"
             }
         },
-        "node_modules/@jest/core/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+        "node_modules/@eslint/eslintrc/node_modules/type-fest": {
+            "version": "0.20.2",
+            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+            "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
             "dev": true,
             "engines": {
                 "node": ">=10"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/@jest/core/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-            "dev": true
-        },
-        "node_modules/@jest/core/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/@eslint/js": {
+            "version": "8.57.0",
+            "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+            "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
             "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
             "engines": {
-                "node": ">=8"
+                "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
             }
         },
-        "node_modules/@jest/environment": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
-            "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
-            "dev": true,
-            "dependencies": {
-                "@jest/fake-timers": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "jest-mock": "^29.7.0"
-            },
+        "node_modules/@fortawesome/fontawesome-common-types": {
+            "version": "6.6.0",
+            "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.6.0.tgz",
+            "integrity": "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw==",
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=6"
             }
         },
-        "node_modules/@jest/expect": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
-            "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
-            "dev": true,
-            "dependencies": {
-                "expect": "^29.7.0",
-                "jest-snapshot": "^29.7.0"
-            },
+        "node_modules/@fortawesome/fontawesome-free": {
+            "version": "6.6.0",
+            "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.6.0.tgz",
+            "integrity": "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow==",
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=6"
             }
         },
-        "node_modules/@jest/expect-utils": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
-            "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
-            "dev": true,
+        "node_modules/@fortawesome/fontawesome-svg-core": {
+            "version": "6.6.0",
+            "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.6.0.tgz",
+            "integrity": "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg==",
             "dependencies": {
-                "jest-get-type": "^29.6.3"
+                "@fortawesome/fontawesome-common-types": "6.6.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=6"
             }
         },
-        "node_modules/@jest/fake-timers": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
-            "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
-            "dev": true,
+        "node_modules/@fortawesome/free-brands-svg-icons": {
+            "version": "6.6.0",
+            "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.6.0.tgz",
+            "integrity": "sha512-1MPD8lMNW/earme4OQi1IFHtmHUwAKgghXlNwWi9GO7QkTfD+IIaYpIai4m2YJEzqfEji3jFHX1DZI5pbY/biQ==",
             "dependencies": {
-                "@jest/types": "^29.6.3",
-                "@sinonjs/fake-timers": "^10.0.2",
-                "@types/node": "*",
-                "jest-message-util": "^29.7.0",
-                "jest-mock": "^29.7.0",
-                "jest-util": "^29.7.0"
+                "@fortawesome/fontawesome-common-types": "6.6.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=6"
             }
         },
-        "node_modules/@jest/globals": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
-            "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
-            "dev": true,
+        "node_modules/@fortawesome/free-solid-svg-icons": {
+            "version": "6.6.0",
+            "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.6.0.tgz",
+            "integrity": "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA==",
             "dependencies": {
-                "@jest/environment": "^29.7.0",
-                "@jest/expect": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "jest-mock": "^29.7.0"
+                "@fortawesome/fontawesome-common-types": "6.6.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=6"
             }
         },
-        "node_modules/@jest/reporters": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
-            "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
-            "dev": true,
+        "node_modules/@fortawesome/react-fontawesome": {
+            "version": "0.2.2",
+            "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz",
+            "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==",
             "dependencies": {
-                "@bcoe/v8-coverage": "^0.2.3",
-                "@jest/console": "^29.7.0",
-                "@jest/test-result": "^29.7.0",
-                "@jest/transform": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@jridgewell/trace-mapping": "^0.3.18",
-                "@types/node": "*",
-                "chalk": "^4.0.0",
-                "collect-v8-coverage": "^1.0.0",
-                "exit": "^0.1.2",
-                "glob": "^7.1.3",
-                "graceful-fs": "^4.2.9",
-                "istanbul-lib-coverage": "^3.0.0",
-                "istanbul-lib-instrument": "^6.0.0",
-                "istanbul-lib-report": "^3.0.0",
-                "istanbul-lib-source-maps": "^4.0.0",
-                "istanbul-reports": "^3.1.3",
-                "jest-message-util": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "jest-worker": "^29.7.0",
-                "slash": "^3.0.0",
-                "string-length": "^4.0.1",
-                "strip-ansi": "^6.0.0",
-                "v8-to-istanbul": "^9.0.1"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "prop-types": "^15.8.1"
             },
             "peerDependencies": {
-                "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-            },
-            "peerDependenciesMeta": {
-                "node-notifier": {
-                    "optional": true
-                }
+                "@fortawesome/fontawesome-svg-core": "~1 || ~6",
+                "react": ">=16.3"
             }
         },
-        "node_modules/@jest/reporters/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/@humanwhocodes/config-array": {
+            "version": "0.11.14",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+            "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+            "deprecated": "Use @eslint/config-array instead",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "@humanwhocodes/object-schema": "^2.0.2",
+                "debug": "^4.3.1",
+                "minimatch": "^3.0.5"
             },
             "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "node": ">=10.10.0"
             }
         },
-        "node_modules/@jest/reporters/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/@humanwhocodes/module-importer": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+            "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
             "engines": {
-                "node": ">=10"
+                "node": ">=12.22"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "type": "github",
+                "url": "https://github.com/sponsors/nzakas"
             }
         },
-        "node_modules/@jest/reporters/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
-            "engines": {
-                "node": ">=7.0.0"
-            }
-        },
-        "node_modules/@jest/reporters/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+        "node_modules/@humanwhocodes/object-schema": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
+            "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
+            "deprecated": "Use @eslint/object-schema instead",
             "dev": true
         },
-        "node_modules/@jest/reporters/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": {
-            "version": "6.0.3",
-            "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz",
-            "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==",
-            "dev": true,
-            "dependencies": {
-                "@babel/core": "^7.23.9",
-                "@babel/parser": "^7.23.9",
-                "@istanbuljs/schema": "^0.1.3",
-                "istanbul-lib-coverage": "^3.2.0",
-                "semver": "^7.5.4"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@jest/reporters/node_modules/semver": {
-            "version": "7.6.3",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
-            "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
-            "dev": true,
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@jest/reporters/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-            "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/@jest/schemas": {
-            "version": "29.6.3",
-            "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-            "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
-            "dev": true,
-            "dependencies": {
-                "@sinclair/typebox": "^0.27.8"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/@jest/source-map": {
-            "version": "29.6.3",
-            "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
-            "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
-            "dev": true,
-            "dependencies": {
-                "@jridgewell/trace-mapping": "^0.3.18",
-                "callsites": "^3.0.0",
-                "graceful-fs": "^4.2.9"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/@jest/test-result": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
-            "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
-            "dev": true,
-            "dependencies": {
-                "@jest/console": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/istanbul-lib-coverage": "^2.0.0",
-                "collect-v8-coverage": "^1.0.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/@jest/test-sequencer": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
-            "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
-            "dev": true,
-            "dependencies": {
-                "@jest/test-result": "^29.7.0",
-                "graceful-fs": "^4.2.9",
-                "jest-haste-map": "^29.7.0",
-                "slash": "^3.0.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/@jest/transform": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
-            "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
+        "node_modules/@isaacs/cliui": {
+            "version": "8.0.2",
+            "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+            "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
             "dev": true,
             "dependencies": {
-                "@babel/core": "^7.11.6",
-                "@jest/types": "^29.6.3",
-                "@jridgewell/trace-mapping": "^0.3.18",
-                "babel-plugin-istanbul": "^6.1.1",
-                "chalk": "^4.0.0",
-                "convert-source-map": "^2.0.0",
-                "fast-json-stable-stringify": "^2.1.0",
-                "graceful-fs": "^4.2.9",
-                "jest-haste-map": "^29.7.0",
-                "jest-regex-util": "^29.6.3",
-                "jest-util": "^29.7.0",
-                "micromatch": "^4.0.4",
-                "pirates": "^4.0.4",
-                "slash": "^3.0.0",
-                "write-file-atomic": "^4.0.2"
+                "string-width": "^5.1.2",
+                "string-width-cjs": "npm:string-width@^4.2.0",
+                "strip-ansi": "^7.0.1",
+                "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+                "wrap-ansi": "^8.1.0",
+                "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=12"
             }
         },
-        "node_modules/@jest/transform/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+            "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
             "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
             "engines": {
-                "node": ">=8"
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/chalk/ansi-regex?sponsor=1"
             }
         },
-        "node_modules/@jest/transform/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
+            "version": "6.2.1",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+            "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
             "engines": {
-                "node": ">=10"
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
-            }
-        },
-        "node_modules/@jest/transform/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
-            "engines": {
-                "node": ">=7.0.0"
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
             }
         },
-        "node_modules/@jest/transform/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+        "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+            "version": "9.2.2",
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
             "dev": true
         },
-        "node_modules/@jest/transform/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/@jest/transform/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-            "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/@jest/types": {
-            "version": "29.6.3",
-            "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
-            "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
-            "dev": true,
-            "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "@types/istanbul-lib-coverage": "^2.0.0",
-                "@types/istanbul-reports": "^3.0.0",
-                "@types/node": "*",
-                "@types/yargs": "^17.0.8",
-                "chalk": "^4.0.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/@jest/types/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/@isaacs/cliui/node_modules/string-width": {
+            "version": "5.1.2",
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+            "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "eastasianwidth": "^0.2.0",
+                "emoji-regex": "^9.2.2",
+                "strip-ansi": "^7.0.1"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/@jest/types/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+            "version": "7.1.0",
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+            "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
             "dev": true,
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "ansi-regex": "^6.0.1"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">=12"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
             }
         },
-        "node_modules/@jest/types/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+            "version": "8.1.0",
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+            "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "ansi-styles": "^6.1.0",
+                "string-width": "^5.0.1",
+                "strip-ansi": "^7.0.1"
             },
             "engines": {
-                "node": ">=7.0.0"
-            }
-        },
-        "node_modules/@jest/types/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/@jest/types/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/@jest/types/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-            "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
+                "node": ">=12"
             },
-            "engines": {
-                "node": ">=8"
+            "funding": {
+                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
             }
         },
         "node_modules/@jridgewell/gen-mapping": {
                 "react": ">=16.14.0"
             }
         },
-        "node_modules/@sinclair/typebox": {
-            "version": "0.27.8",
-            "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-            "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
-            "dev": true
-        },
-        "node_modules/@sinonjs/commons": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
-            "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+        "node_modules/@rolldown/pluginutils": {
+            "version": "1.0.0-beta.11",
+            "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
+            "integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==",
             "dev": true,
-            "dependencies": {
-                "type-detect": "4.0.8"
-            }
+            "license": "MIT"
         },
-        "node_modules/@sinonjs/fake-timers": {
-            "version": "10.3.0",
-            "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
-            "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
+        "node_modules/@rollup/plugin-alias": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz",
+            "integrity": "sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==",
             "dev": true,
-            "dependencies": {
-                "@sinonjs/commons": "^3.0.0"
+            "license": "MIT",
+            "engines": {
+                "node": ">=14.0.0"
+            },
+            "peerDependencies": {
+                "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+            },
+            "peerDependenciesMeta": {
+                "rollup": {
+                    "optional": true
+                }
             }
         },
-        "node_modules/@swc/helpers": {
-            "version": "0.5.12",
-            "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.12.tgz",
-            "integrity": "sha512-KMZNXiGibsW9kvZAO1Pam2JPTDBm+KSHMMHWdsyI/1DbIZjT2A6Gy3hblVXUMEDvUAKq+e0vL0X0o54owWji7g==",
+        "node_modules/@rollup/pluginutils": {
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz",
+            "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==",
+            "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "tslib": "^2.4.0"
+                "@types/estree": "^1.0.0",
+                "estree-walker": "^2.0.2",
+                "picomatch": "^4.0.2"
+            },
+            "engines": {
+                "node": ">=14.0.0"
+            },
+            "peerDependencies": {
+                "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+            },
+            "peerDependenciesMeta": {
+                "rollup": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@rollup/pluginutils/node_modules/picomatch": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+            "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/jonschlinkert"
+            }
+        },
+        "node_modules/@rollup/rollup-android-arm-eabi": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz",
+            "integrity": "sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==",
+            "cpu": [
+                "arm"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ]
+        },
+        "node_modules/@rollup/rollup-android-arm64": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.0.tgz",
+            "integrity": "sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ]
+        },
+        "node_modules/@rollup/rollup-darwin-arm64": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz",
+            "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ]
+        },
+        "node_modules/@rollup/rollup-darwin-x64": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz",
+            "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ]
+        },
+        "node_modules/@rollup/rollup-freebsd-arm64": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.0.tgz",
+            "integrity": "sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ]
+        },
+        "node_modules/@rollup/rollup-freebsd-x64": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.0.tgz",
+            "integrity": "sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.0.tgz",
+            "integrity": "sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==",
+            "cpu": [
+                "arm"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.0.tgz",
+            "integrity": "sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==",
+            "cpu": [
+                "arm"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-arm64-gnu": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz",
+            "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-arm64-musl": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz",
+            "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.0.tgz",
+            "integrity": "sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==",
+            "cpu": [
+                "loong64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.0.tgz",
+            "integrity": "sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==",
+            "cpu": [
+                "ppc64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.0.tgz",
+            "integrity": "sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==",
+            "cpu": [
+                "riscv64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-riscv64-musl": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.0.tgz",
+            "integrity": "sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==",
+            "cpu": [
+                "riscv64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-s390x-gnu": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.0.tgz",
+            "integrity": "sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==",
+            "cpu": [
+                "s390x"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-x64-gnu": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz",
+            "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-x64-musl": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz",
+            "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-win32-arm64-msvc": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz",
+            "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
+        "node_modules/@rollup/rollup-win32-ia32-msvc": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.0.tgz",
+            "integrity": "sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==",
+            "cpu": [
+                "ia32"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
+        "node_modules/@rollup/rollup-win32-x64-msvc": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz",
+            "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
+        "node_modules/@swc/helpers": {
+            "version": "0.5.12",
+            "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.12.tgz",
+            "integrity": "sha512-KMZNXiGibsW9kvZAO1Pam2JPTDBm+KSHMMHWdsyI/1DbIZjT2A6Gy3hblVXUMEDvUAKq+e0vL0X0o54owWji7g==",
+            "dependencies": {
+                "tslib": "^2.4.0"
             }
         },
         "node_modules/@tailwindcss/forms": {
                 "react-dom": "^18.0.0"
             }
         },
-        "node_modules/@tootallnate/once": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
-            "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
-            "dev": true,
-            "engines": {
-                "node": ">= 10"
-            }
-        },
         "node_modules/@trysound/sax": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
                 "@types/node": "*"
             }
         },
+        "node_modules/@types/chai": {
+            "version": "5.2.2",
+            "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
+            "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/deep-eql": "*"
+            }
+        },
         "node_modules/@types/clean-css": {
             "version": "4.2.11",
             "resolved": "https://registry.npmjs.org/@types/clean-css/-/clean-css-4.2.11.tgz",
             "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
             "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
         },
+        "node_modules/@types/deep-eql": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+            "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/@types/estree": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
-            "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
-            "dev": true
+            "version": "1.0.8",
+            "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+            "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/@types/express": {
             "version": "4.17.21",
                 "@types/node": "*"
             }
         },
-        "node_modules/@types/graceful-fs": {
-            "version": "4.1.9",
-            "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
-            "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==",
-            "dev": true,
-            "dependencies": {
-                "@types/node": "*"
-            }
-        },
         "node_modules/@types/hoist-non-react-statics": {
             "version": "3.3.5",
             "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
                 "@types/svgo": "^1"
             }
         },
-        "node_modules/@types/istanbul-lib-coverage": {
-            "version": "2.0.6",
-            "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
-            "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
-            "dev": true
-        },
-        "node_modules/@types/istanbul-lib-report": {
-            "version": "3.0.3",
-            "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz",
-            "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
-            "dev": true,
-            "dependencies": {
-                "@types/istanbul-lib-coverage": "*"
-            }
-        },
-        "node_modules/@types/istanbul-reports": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
-            "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
-            "dev": true,
-            "dependencies": {
-                "@types/istanbul-lib-report": "*"
-            }
-        },
-        "node_modules/@types/jsdom": {
-            "version": "20.0.1",
-            "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",
-            "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==",
-            "dev": true,
-            "dependencies": {
-                "@types/node": "*",
-                "@types/tough-cookie": "*",
-                "parse5": "^7.0.0"
-            }
-        },
         "node_modules/@types/json-schema": {
             "version": "7.0.15",
             "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
                 "@types/node": "*"
             }
         },
-        "node_modules/@types/stack-utils": {
-            "version": "2.0.3",
-            "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
-            "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
-            "dev": true
-        },
         "node_modules/@types/svgo": {
             "version": "1.3.6",
             "resolved": "https://registry.npmjs.org/@types/svgo/-/svgo-1.3.6.tgz",
             "integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug==",
             "dev": true
         },
-        "node_modules/@types/tough-cookie": {
-            "version": "4.0.5",
-            "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
-            "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
-            "dev": true
-        },
         "node_modules/@types/warning": {
             "version": "3.0.3",
             "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
                 "@types/node": "*"
             }
         },
-        "node_modules/@types/yargs": {
-            "version": "17.0.33",
-            "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
-            "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==",
-            "dev": true,
-            "dependencies": {
-                "@types/yargs-parser": "*"
-            }
-        },
-        "node_modules/@types/yargs-parser": {
-            "version": "21.0.3",
-            "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
-            "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
-            "dev": true
-        },
         "node_modules/@uiw/codemirror-extensions-basic-setup": {
             "version": "4.23.0",
             "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.23.0.tgz",
             "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
             "dev": true
         },
-        "node_modules/@vue/reactivity": {
-            "version": "3.1.5",
-            "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
-            "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+        "node_modules/@vitejs/plugin-react": {
+            "version": "4.5.2",
+            "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz",
+            "integrity": "sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@vue/shared": "3.1.5"
+                "@babel/core": "^7.27.4",
+                "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+                "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+                "@rolldown/pluginutils": "1.0.0-beta.11",
+                "@types/babel__core": "^7.20.5",
+                "react-refresh": "^0.17.0"
+            },
+            "engines": {
+                "node": "^14.18.0 || >=16.0.0"
+            },
+            "peerDependencies": {
+                "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
             }
         },
-        "node_modules/@vue/shared": {
-            "version": "3.1.5",
-            "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
-            "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
-            "dev": true
-        },
-        "node_modules/@webassemblyjs/ast": {
-            "version": "1.12.1",
-            "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz",
-            "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==",
+        "node_modules/@vitest/expect": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+            "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@webassemblyjs/helper-numbers": "1.11.6",
-                "@webassemblyjs/helper-wasm-bytecode": "1.11.6"
+                "@types/chai": "^5.2.2",
+                "@vitest/spy": "3.2.4",
+                "@vitest/utils": "3.2.4",
+                "chai": "^5.2.0",
+                "tinyrainbow": "^2.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
             }
         },
-        "node_modules/@webassemblyjs/floating-point-hex-parser": {
-            "version": "1.11.6",
-            "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz",
-            "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==",
-            "dev": true
-        },
-        "node_modules/@webassemblyjs/helper-api-error": {
-            "version": "1.11.6",
-            "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz",
-            "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==",
-            "dev": true
-        },
-        "node_modules/@webassemblyjs/helper-buffer": {
-            "version": "1.12.1",
-            "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz",
-            "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==",
-            "dev": true
-        },
-        "node_modules/@webassemblyjs/helper-numbers": {
-            "version": "1.11.6",
-            "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz",
-            "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==",
+        "node_modules/@vitest/mocker": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+            "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@webassemblyjs/floating-point-hex-parser": "1.11.6",
-                "@webassemblyjs/helper-api-error": "1.11.6",
-                "@xtuc/long": "4.2.2"
-            }
-        },
-        "node_modules/@webassemblyjs/helper-wasm-bytecode": {
-            "version": "1.11.6",
-            "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz",
+                "@vitest/spy": "3.2.4",
+                "estree-walker": "^3.0.3",
+                "magic-string": "^0.30.17"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            },
+            "peerDependencies": {
+                "msw": "^2.4.9",
+                "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+            },
+            "peerDependenciesMeta": {
+                "msw": {
+                    "optional": true
+                },
+                "vite": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/@vitest/mocker/node_modules/estree-walker": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+            "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/estree": "^1.0.0"
+            }
+        },
+        "node_modules/@vitest/pretty-format": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+            "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "tinyrainbow": "^2.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vitest/runner": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+            "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@vitest/utils": "3.2.4",
+                "pathe": "^2.0.3",
+                "strip-literal": "^3.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vitest/snapshot": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+            "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@vitest/pretty-format": "3.2.4",
+                "magic-string": "^0.30.17",
+                "pathe": "^2.0.3"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vitest/spy": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+            "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "tinyspy": "^4.0.3"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vitest/utils": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+            "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@vitest/pretty-format": "3.2.4",
+                "loupe": "^3.1.4",
+                "tinyrainbow": "^2.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vue/reactivity": {
+            "version": "3.1.5",
+            "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
+            "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+            "dev": true,
+            "dependencies": {
+                "@vue/shared": "3.1.5"
+            }
+        },
+        "node_modules/@vue/shared": {
+            "version": "3.1.5",
+            "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
+            "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
+            "dev": true
+        },
+        "node_modules/@webassemblyjs/ast": {
+            "version": "1.12.1",
+            "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz",
+            "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==",
+            "dev": true,
+            "dependencies": {
+                "@webassemblyjs/helper-numbers": "1.11.6",
+                "@webassemblyjs/helper-wasm-bytecode": "1.11.6"
+            }
+        },
+        "node_modules/@webassemblyjs/floating-point-hex-parser": {
+            "version": "1.11.6",
+            "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz",
+            "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==",
+            "dev": true
+        },
+        "node_modules/@webassemblyjs/helper-api-error": {
+            "version": "1.11.6",
+            "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz",
+            "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==",
+            "dev": true
+        },
+        "node_modules/@webassemblyjs/helper-buffer": {
+            "version": "1.12.1",
+            "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz",
+            "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==",
+            "dev": true
+        },
+        "node_modules/@webassemblyjs/helper-numbers": {
+            "version": "1.11.6",
+            "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz",
+            "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==",
+            "dev": true,
+            "dependencies": {
+                "@webassemblyjs/floating-point-hex-parser": "1.11.6",
+                "@webassemblyjs/helper-api-error": "1.11.6",
+                "@xtuc/long": "4.2.2"
+            }
+        },
+        "node_modules/@webassemblyjs/helper-wasm-bytecode": {
+            "version": "1.11.6",
+            "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz",
             "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==",
             "dev": true
         },
             "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
             "dev": true
         },
-        "node_modules/abab": {
-            "version": "2.0.6",
-            "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
-            "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
-            "deprecated": "Use your platform's native atob() and btoa() methods instead",
-            "dev": true
-        },
         "node_modules/accepts": {
             "version": "1.3.8",
             "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
                 "node": ">=0.4.0"
             }
         },
-        "node_modules/acorn-globals": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz",
-            "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==",
-            "dev": true,
-            "dependencies": {
-                "acorn": "^8.1.0",
-                "acorn-walk": "^8.0.2"
-            }
-        },
         "node_modules/acorn-import-attributes": {
             "version": "1.9.5",
             "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
                 "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
             }
         },
-        "node_modules/acorn-walk": {
-            "version": "8.3.3",
-            "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
-            "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
-            "dev": true,
-            "dependencies": {
-                "acorn": "^8.11.0"
-            },
-            "engines": {
-                "node": ">=0.4.0"
-            }
-        },
         "node_modules/adjust-sourcemap-loader": {
             "version": "4.0.0",
             "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz",
             }
         },
         "node_modules/agent-base": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
-            "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+            "version": "7.1.3",
+            "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
+            "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
             "dev": true,
-            "dependencies": {
-                "debug": "4"
-            },
+            "license": "MIT",
             "engines": {
-                "node": ">= 6.0.0"
+                "node": ">= 14"
             }
         },
         "node_modules/ajv": {
                 "@vue/reactivity": "~3.1.1"
             }
         },
-        "node_modules/ansi-escapes": {
-            "version": "4.3.2",
-            "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
-            "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
-            "dev": true,
-            "dependencies": {
-                "type-fest": "^0.21.3"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
         "node_modules/ansi-html-community": {
             "version": "0.0.8",
             "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
                 "node": ">=8"
             }
         },
-        "node_modules/ansi-styles": {
-            "version": "3.2.1",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
-            "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
-            "dev": true,
-            "dependencies": {
-                "color-convert": "^1.9.0"
-            },
-            "engines": {
-                "node": ">=4"
-            }
-        },
         "node_modules/any-promise": {
             "version": "1.3.0",
             "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
             "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
             "dev": true
         },
-        "node_modules/argparse": {
-            "version": "1.0.10",
-            "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
-            "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
-            "dev": true,
-            "dependencies": {
-                "sprintf-js": "~1.0.2"
-            }
-        },
         "node_modules/aria-query": {
             "version": "5.3.0",
             "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
                 "inherits": "2.0.3"
             }
         },
+        "node_modules/assertion-error": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+            "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=12"
+            }
+        },
         "node_modules/asynckit": {
             "version": "0.4.0",
             "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
                 "proxy-from-env": "^1.1.0"
             }
         },
-        "node_modules/babel-jest": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
-            "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
-            "dev": true,
-            "dependencies": {
-                "@jest/transform": "^29.7.0",
-                "@types/babel__core": "^7.1.14",
-                "babel-plugin-istanbul": "^6.1.1",
-                "babel-preset-jest": "^29.6.3",
-                "chalk": "^4.0.0",
-                "graceful-fs": "^4.2.9",
-                "slash": "^3.0.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            },
-            "peerDependencies": {
-                "@babel/core": "^7.8.0"
-            }
-        },
-        "node_modules/babel-jest/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-            "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-            }
-        },
-        "node_modules/babel-jest/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-            "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
-            }
-        },
-        "node_modules/babel-jest/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
-            "engines": {
-                "node": ">=7.0.0"
-            }
-        },
-        "node_modules/babel-jest/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/babel-jest/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/babel-jest/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-            "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
         "node_modules/babel-loader": {
             "version": "8.3.0",
             "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz",
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/babel-plugin-istanbul": {
-            "version": "6.1.1",
-            "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
-            "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
-            "dev": true,
-            "dependencies": {
-                "@babel/helper-plugin-utils": "^7.0.0",
-                "@istanbuljs/load-nyc-config": "^1.0.0",
-                "@istanbuljs/schema": "^0.1.2",
-                "istanbul-lib-instrument": "^5.0.4",
-                "test-exclude": "^6.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/babel-plugin-jest-hoist": {
-            "version": "29.6.3",
-            "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz",
-            "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==",
-            "dev": true,
-            "dependencies": {
-                "@babel/template": "^7.3.3",
-                "@babel/types": "^7.3.3",
-                "@types/babel__core": "^7.1.14",
-                "@types/babel__traverse": "^7.0.6"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
         "node_modules/babel-plugin-polyfill-corejs2": {
             "version": "0.4.11",
             "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz",
                 "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
             }
         },
-        "node_modules/babel-preset-current-node-syntax": {
-            "version": "1.1.0",
-            "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz",
-            "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==",
-            "dev": true,
-            "dependencies": {
-                "@babel/plugin-syntax-async-generators": "^7.8.4",
-                "@babel/plugin-syntax-bigint": "^7.8.3",
-                "@babel/plugin-syntax-class-properties": "^7.12.13",
-                "@babel/plugin-syntax-class-static-block": "^7.14.5",
-                "@babel/plugin-syntax-import-attributes": "^7.24.7",
-                "@babel/plugin-syntax-import-meta": "^7.10.4",
-                "@babel/plugin-syntax-json-strings": "^7.8.3",
-                "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
-                "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
-                "@babel/plugin-syntax-numeric-separator": "^7.10.4",
-                "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
-                "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
-                "@babel/plugin-syntax-optional-chaining": "^7.8.3",
-                "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
-                "@babel/plugin-syntax-top-level-await": "^7.14.5"
-            },
-            "peerDependencies": {
-                "@babel/core": "^7.0.0"
-            }
-        },
-        "node_modules/babel-preset-jest": {
-            "version": "29.6.3",
-            "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz",
-            "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==",
-            "dev": true,
-            "dependencies": {
-                "babel-plugin-jest-hoist": "^29.6.3",
-                "babel-preset-current-node-syntax": "^1.0.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            },
-            "peerDependencies": {
-                "@babel/core": "^7.0.0"
-            }
-        },
         "node_modules/balanced-match": {
             "version": "1.0.2",
             "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
             }
         },
         "node_modules/browserslist": {
-            "version": "4.23.3",
-            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
-            "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
+            "version": "4.25.0",
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
+            "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==",
             "dev": true,
             "funding": [
                 {
                     "url": "https://github.com/sponsors/ai"
                 }
             ],
+            "license": "MIT",
             "dependencies": {
-                "caniuse-lite": "^1.0.30001646",
-                "electron-to-chromium": "^1.5.4",
-                "node-releases": "^2.0.18",
-                "update-browserslist-db": "^1.1.0"
+                "caniuse-lite": "^1.0.30001718",
+                "electron-to-chromium": "^1.5.160",
+                "node-releases": "^2.0.19",
+                "update-browserslist-db": "^1.1.3"
             },
             "bin": {
                 "browserslist": "cli.js"
                 "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
             }
         },
-        "node_modules/bser": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
-            "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
-            "dev": true,
-            "dependencies": {
-                "node-int64": "^0.4.0"
-            }
-        },
         "node_modules/buffer": {
             "version": "4.9.2",
             "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz",
                 "node": ">= 0.8"
             }
         },
+        "node_modules/cac": {
+            "version": "6.7.14",
+            "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+            "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/call-bind": {
             "version": "1.0.7",
             "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
                 "tslib": "^2.0.3"
             }
         },
-        "node_modules/camelcase": {
-            "version": "5.3.1",
-            "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
-            "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
-            "dev": true,
-            "engines": {
-                "node": ">=6"
-            }
-        },
         "node_modules/camelcase-css": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
             }
         },
         "node_modules/caniuse-lite": {
-            "version": "1.0.30001655",
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz",
-            "integrity": "sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==",
+            "version": "1.0.30001724",
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz",
+            "integrity": "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA==",
             "dev": true,
             "funding": [
                 {
                     "type": "github",
                     "url": "https://github.com/sponsors/ai"
                 }
-            ]
+            ],
+            "license": "CC-BY-4.0"
         },
-        "node_modules/chalk": {
-            "version": "2.4.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
-            "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+        "node_modules/chai": {
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
+            "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "ansi-styles": "^3.2.1",
-                "escape-string-regexp": "^1.0.5",
-                "supports-color": "^5.3.0"
+                "assertion-error": "^2.0.1",
+                "check-error": "^2.1.1",
+                "deep-eql": "^5.0.1",
+                "loupe": "^3.1.0",
+                "pathval": "^2.0.0"
             },
             "engines": {
-                "node": ">=4"
-            }
-        },
-        "node_modules/char-regex": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
-            "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
+                "node": ">=12"
             }
         },
         "node_modules/charenc": {
                 "node": "*"
             }
         },
+        "node_modules/check-error": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+            "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">= 16"
+            }
+        },
         "node_modules/chokidar": {
             "version": "3.6.0",
             "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
                 "node": ">=6.0"
             }
         },
-        "node_modules/ci-info": {
-            "version": "3.9.0",
-            "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
-            "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/sibiraj-s"
-                }
-            ],
-            "engines": {
-                "node": ">=8"
-            }
-        },
         "node_modules/cipher-base": {
             "version": "1.0.4",
             "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
                 "safe-buffer": "^5.0.1"
             }
         },
-        "node_modules/cjs-module-lexer": {
-            "version": "1.4.0",
-            "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.0.tgz",
-            "integrity": "sha512-N1NGmowPlGBLsOZLPvm48StN04V4YvQRL0i6b7ctrVY3epjP/ct7hFLOItz6pDIvRjwpfPxi52a2UWV2ziir8g==",
-            "dev": true
-        },
         "node_modules/classnames": {
             "version": "2.5.1",
             "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
                 "node": ">=6"
             }
         },
-        "node_modules/co": {
-            "version": "4.6.0",
-            "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
-            "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
-            "dev": true,
-            "engines": {
-                "iojs": ">= 1.0.0",
-                "node": ">= 0.12.0"
-            }
-        },
         "node_modules/codemirror": {
             "version": "6.0.1",
             "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz",
                 "@codemirror/view": "^6.0.0"
             }
         },
-        "node_modules/collect-v8-coverage": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
-            "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
-            "dev": true
-        },
         "node_modules/collect.js": {
             "version": "4.36.1",
             "resolved": "https://registry.npmjs.org/collect.js/-/collect.js-4.36.1.tgz",
             "integrity": "sha512-jd97xWPKgHn6uvK31V6zcyPd40lUJd7gpYxbN2VOVxGWO4tyvS9Li4EpsFjXepGTo2tYcOTC4a8YsbQXMJ4XUw==",
             "dev": true
         },
-        "node_modules/color-convert": {
-            "version": "1.9.3",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
-            "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
-            "dev": true,
-            "dependencies": {
-                "color-name": "1.1.3"
-            }
-        },
-        "node_modules/color-name": {
-            "version": "1.1.3",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
-            "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
-            "dev": true
-        },
         "node_modules/colord": {
             "version": "2.9.3",
             "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
                 "node": ">=10"
             }
         },
+        "node_modules/cosmiconfig/node_modules/yaml": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+            "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+            "dev": true,
+            "license": "ISC",
+            "engines": {
+                "node": ">= 6"
+            }
+        },
         "node_modules/crc-32": {
             "version": "1.2.2",
             "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
                 "sha.js": "^2.4.8"
             }
         },
-        "node_modules/create-jest": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
-            "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==",
-            "dev": true,
-            "dependencies": {
-                "@jest/types": "^29.6.3",
-                "chalk": "^4.0.0",
-                "exit": "^0.1.2",
-                "graceful-fs": "^4.2.9",
-                "jest-config": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "prompts": "^2.0.1"
-            },
-            "bin": {
-                "create-jest": "bin/create-jest.js"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/create-jest/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-            "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-            }
-        },
-        "node_modules/create-jest/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-            "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
-            }
-        },
-        "node_modules/create-jest/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
-            "engines": {
-                "node": ">=7.0.0"
-            }
-        },
-        "node_modules/create-jest/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/create-jest/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/create-jest/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-            "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
         "node_modules/crelt": {
             "version": "1.0.6",
             "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
                 "postcss": "^8.2.15"
             }
         },
+        "node_modules/cssnano/node_modules/yaml": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+            "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+            "dev": true,
+            "license": "ISC",
+            "engines": {
+                "node": ">= 6"
+            }
+        },
         "node_modules/csso": {
             "version": "4.2.0",
             "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz",
                 "node": ">=8.0.0"
             }
         },
-        "node_modules/cssom": {
-            "version": "0.5.0",
-            "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
-            "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
-            "dev": true
-        },
         "node_modules/cssstyle": {
-            "version": "2.3.0",
-            "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
-            "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
+            "version": "4.5.0",
+            "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.5.0.tgz",
+            "integrity": "sha512-/7gw8TGrvH/0g564EnhgFZogTMVe+lifpB7LWU+PEsiq5o83TUXR3fDbzTRXOJhoJwck5IS9ez3Em5LNMMO2aw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "cssom": "~0.3.6"
+                "@asamuzakjp/css-color": "^3.2.0",
+                "rrweb-cssom": "^0.8.0"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">=18"
             }
         },
-        "node_modules/cssstyle/node_modules/cssom": {
-            "version": "0.3.8",
-            "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
-            "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
-            "dev": true
-        },
         "node_modules/csstype": {
             "version": "3.1.3",
             "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
             }
         },
         "node_modules/data-urls": {
-            "version": "3.0.2",
-            "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
-            "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==",
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+            "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "abab": "^2.0.6",
-                "whatwg-mimetype": "^3.0.0",
-                "whatwg-url": "^11.0.0"
+                "whatwg-mimetype": "^4.0.0",
+                "whatwg-url": "^14.0.0"
             },
             "engines": {
-                "node": ">=12"
+                "node": ">=18"
             }
         },
         "node_modules/data-view-buffer": {
             }
         },
         "node_modules/debug": {
-            "version": "4.3.6",
-            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
-            "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
+            "version": "4.4.1",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+            "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "ms": "2.1.2"
+                "ms": "^2.1.3"
             },
             "engines": {
                 "node": ">=6.0"
             }
         },
         "node_modules/decimal.js": {
-            "version": "10.4.3",
-            "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
-            "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
-            "dev": true
+            "version": "10.5.0",
+            "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
+            "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/decimal.js-light": {
             "version": "2.5.1",
             "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
             "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
         },
-        "node_modules/dedent": {
-            "version": "1.5.3",
-            "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz",
-            "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==",
+        "node_modules/deep-eql": {
+            "version": "5.0.2",
+            "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+            "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
             "dev": true,
-            "peerDependencies": {
-                "babel-plugin-macros": "^3.1.0"
-            },
-            "peerDependenciesMeta": {
-                "babel-plugin-macros": {
-                    "optional": true
-                }
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
             }
         },
         "node_modules/deep-equal": {
                 "npm": "1.2.8000 || >= 1.4.16"
             }
         },
-        "node_modules/detect-newline": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
-            "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
         "node_modules/detect-node": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
             "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
             "dev": true
         },
-        "node_modules/diff-sequences": {
-            "version": "29.6.3",
-            "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
-            "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
-            "dev": true,
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
         "node_modules/diffie-hellman": {
             "version": "5.0.3",
             "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
                 }
             ]
         },
-        "node_modules/domexception": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
-            "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==",
-            "deprecated": "Use your platform's native DOMException instead",
-            "dev": true,
-            "dependencies": {
-                "webidl-conversions": "^7.0.0"
-            },
-            "engines": {
-                "node": ">=12"
-            }
-        },
         "node_modules/domhandler": {
             "version": "3.3.0",
             "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
             "dev": true
         },
         "node_modules/electron-to-chromium": {
-            "version": "1.5.13",
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz",
-            "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==",
-            "dev": true
+            "version": "1.5.171",
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.171.tgz",
+            "integrity": "sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==",
+            "dev": true,
+            "license": "ISC"
         },
         "node_modules/elliptic": {
             "version": "6.5.7",
             "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
             "dev": true
         },
-        "node_modules/emittery": {
-            "version": "0.13.1",
-            "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
-            "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=12"
-            },
-            "funding": {
-                "url": "https://github.com/sindresorhus/emittery?sponsor=1"
-            }
-        },
         "node_modules/emoji-regex": {
             "version": "8.0.0",
             "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
             }
         },
         "node_modules/es-module-lexer": {
-            "version": "1.5.4",
-            "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
-            "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
-            "dev": true
+            "version": "1.7.0",
+            "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+            "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/es-object-atoms": {
             "version": "1.0.0",
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/esbuild": {
+            "version": "0.25.5",
+            "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
+            "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
+            "dev": true,
+            "hasInstallScript": true,
+            "license": "MIT",
+            "bin": {
+                "esbuild": "bin/esbuild"
+            },
+            "engines": {
+                "node": ">=18"
+            },
+            "optionalDependencies": {
+                "@esbuild/aix-ppc64": "0.25.5",
+                "@esbuild/android-arm": "0.25.5",
+                "@esbuild/android-arm64": "0.25.5",
+                "@esbuild/android-x64": "0.25.5",
+                "@esbuild/darwin-arm64": "0.25.5",
+                "@esbuild/darwin-x64": "0.25.5",
+                "@esbuild/freebsd-arm64": "0.25.5",
+                "@esbuild/freebsd-x64": "0.25.5",
+                "@esbuild/linux-arm": "0.25.5",
+                "@esbuild/linux-arm64": "0.25.5",
+                "@esbuild/linux-ia32": "0.25.5",
+                "@esbuild/linux-loong64": "0.25.5",
+                "@esbuild/linux-mips64el": "0.25.5",
+                "@esbuild/linux-ppc64": "0.25.5",
+                "@esbuild/linux-riscv64": "0.25.5",
+                "@esbuild/linux-s390x": "0.25.5",
+                "@esbuild/linux-x64": "0.25.5",
+                "@esbuild/netbsd-arm64": "0.25.5",
+                "@esbuild/netbsd-x64": "0.25.5",
+                "@esbuild/openbsd-arm64": "0.25.5",
+                "@esbuild/openbsd-x64": "0.25.5",
+                "@esbuild/sunos-x64": "0.25.5",
+                "@esbuild/win32-arm64": "0.25.5",
+                "@esbuild/win32-ia32": "0.25.5",
+                "@esbuild/win32-x64": "0.25.5"
+            }
+        },
         "node_modules/escalade": {
             "version": "3.2.0",
             "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
             "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
             "dev": true
         },
-        "node_modules/escape-string-regexp": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
-            "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.8.0"
-            }
-        },
-        "node_modules/escodegen": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
-            "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
-            "dev": true,
-            "dependencies": {
-                "esprima": "^4.0.1",
-                "estraverse": "^5.2.0",
-                "esutils": "^2.0.2"
-            },
-            "bin": {
-                "escodegen": "bin/escodegen.js",
-                "esgenerate": "bin/esgenerate.js"
-            },
-            "engines": {
-                "node": ">=6.0"
-            },
-            "optionalDependencies": {
-                "source-map": "~0.6.1"
-            }
-        },
         "node_modules/eslint": {
             "version": "8.57.0",
             "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
                 "url": "https://opencollective.com/eslint"
             }
         },
-        "node_modules/esprima": {
-            "version": "4.0.1",
-            "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
-            "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
-            "dev": true,
-            "bin": {
-                "esparse": "bin/esparse.js",
-                "esvalidate": "bin/esvalidate.js"
-            },
-            "engines": {
-                "node": ">=4"
-            }
-        },
         "node_modules/esquery": {
             "version": "1.6.0",
             "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
                 "node": ">=4.0"
             }
         },
+        "node_modules/estree-walker": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+            "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/esutils": {
             "version": "2.0.3",
             "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
                 "url": "https://github.com/sindresorhus/execa?sponsor=1"
             }
         },
-        "node_modules/exit": {
-            "version": "0.1.2",
-            "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
-            "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.8.0"
-            }
-        },
-        "node_modules/expect": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
-            "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
+        "node_modules/expect-type": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
+            "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
             "dev": true,
-            "dependencies": {
-                "@jest/expect-utils": "^29.7.0",
-                "jest-get-type": "^29.6.3",
-                "jest-matcher-utils": "^29.7.0",
-                "jest-message-util": "^29.7.0",
-                "jest-util": "^29.7.0"
-            },
+            "license": "Apache-2.0",
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=12.0.0"
             }
         },
         "node_modules/express": {
                 "node": ">=8.6.0"
             }
         },
-        "node_modules/fast-glob/node_modules/glob-parent": {
-            "version": "5.1.2",
-            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
-            "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
-            "dev": true,
-            "dependencies": {
-                "is-glob": "^4.0.1"
-            },
-            "engines": {
-                "node": ">= 6"
-            }
-        },
-        "node_modules/fast-json-stable-stringify": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
-            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
-            "dev": true
-        },
-        "node_modules/fast-levenshtein": {
-            "version": "2.0.6",
-            "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
-            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
-            "dev": true
-        },
-        "node_modules/fast-uri": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz",
-            "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==",
-            "dev": true
-        },
-        "node_modules/fastest-levenshtein": {
-            "version": "1.0.16",
-            "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
-            "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
-            "dev": true,
-            "engines": {
-                "node": ">= 4.9.1"
-            }
-        },
-        "node_modules/fastq": {
-            "version": "1.17.1",
-            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
-            "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
-            "dev": true,
-            "dependencies": {
-                "reusify": "^1.0.4"
-            }
-        },
-        "node_modules/faye-websocket": {
-            "version": "0.11.4",
-            "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
-            "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
-            "dev": true,
-            "dependencies": {
-                "websocket-driver": ">=0.5.1"
-            },
-            "engines": {
-                "node": ">=0.8.0"
-            }
-        },
-        "node_modules/fb-watchman": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
-            "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
-            "dev": true,
-            "dependencies": {
-                "bser": "2.1.1"
-            }
-        },
-        "node_modules/file-entry-cache": {
-            "version": "6.0.1",
-            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
-            "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
-            "dev": true,
-            "dependencies": {
-                "flat-cache": "^3.0.4"
-            },
-            "engines": {
-                "node": "^10.12.0 || >=12.0.0"
-            }
-        },
-        "node_modules/file-loader": {
-            "version": "6.2.0",
-            "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
-            "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
-            "dev": true,
-            "dependencies": {
-                "loader-utils": "^2.0.0",
-                "schema-utils": "^3.0.0"
-            },
-            "engines": {
-                "node": ">= 10.13.0"
-            },
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/webpack"
-            },
-            "peerDependencies": {
-                "webpack": "^4.0.0 || ^5.0.0"
-            }
-        },
-        "node_modules/file-loader/node_modules/schema-utils": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
-            "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
-            "dev": true,
-            "dependencies": {
-                "@types/json-schema": "^7.0.8",
-                "ajv": "^6.12.5",
-                "ajv-keywords": "^3.5.2"
-            },
-            "engines": {
-                "node": ">= 10.13.0"
-            },
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/webpack"
-            }
-        },
-        "node_modules/file-saver": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
-            "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
-        },
-        "node_modules/file-type": {
-            "version": "12.4.2",
-            "resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz",
-            "integrity": "sha512-UssQP5ZgIOKelfsaB5CuGAL+Y+q7EmONuiwF3N5HAH0t27rvrttgi6Ra9k/+DVaY9UF6+ybxu5pOXLUdA8N7Vg==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/fill-range": {
-            "version": "7.1.1",
-            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
-            "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
-            "dev": true,
-            "dependencies": {
-                "to-regex-range": "^5.0.1"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/finalhandler": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
-            "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
-            "dev": true,
-            "dependencies": {
-                "debug": "2.6.9",
-                "encodeurl": "~1.0.2",
-                "escape-html": "~1.0.3",
-                "on-finished": "2.4.1",
-                "parseurl": "~1.3.3",
-                "statuses": "2.0.1",
-                "unpipe": "~1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.8"
-            }
-        },
-        "node_modules/finalhandler/node_modules/debug": {
-            "version": "2.6.9",
-            "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
-            "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
-            "dev": true,
-            "dependencies": {
-                "ms": "2.0.0"
-            }
-        },
-        "node_modules/finalhandler/node_modules/ms": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-            "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
-            "dev": true
-        },
-        "node_modules/find-cache-dir": {
-            "version": "3.3.2",
-            "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
-            "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
-            "dev": true,
-            "dependencies": {
-                "commondir": "^1.0.1",
-                "make-dir": "^3.0.2",
-                "pkg-dir": "^4.1.0"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
-            }
-        },
-        "node_modules/find-cache-dir/node_modules/make-dir": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
-            "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
-            "dev": true,
-            "dependencies": {
-                "semver": "^6.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/find-up": {
-            "version": "4.1.0",
-            "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
-            "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
-            "dev": true,
-            "dependencies": {
-                "locate-path": "^5.0.0",
-                "path-exists": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/flat": {
-            "version": "5.0.2",
-            "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
-            "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
-            "dev": true,
-            "bin": {
-                "flat": "cli.js"
-            }
-        },
-        "node_modules/flat-cache": {
-            "version": "3.2.0",
-            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
-            "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
-            "dev": true,
-            "dependencies": {
-                "flatted": "^3.2.9",
-                "keyv": "^4.5.3",
-                "rimraf": "^3.0.2"
-            },
-            "engines": {
-                "node": "^10.12.0 || >=12.0.0"
-            }
-        },
-        "node_modules/flatted": {
-            "version": "3.3.1",
-            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
-            "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
-            "dev": true
-        },
-        "node_modules/follow-redirects": {
-            "version": "1.15.6",
-            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
-            "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "individual",
-                    "url": "https://github.com/sponsors/RubenVerborgh"
-                }
-            ],
-            "engines": {
-                "node": ">=4.0"
-            },
-            "peerDependenciesMeta": {
-                "debug": {
-                    "optional": true
-                }
-            }
-        },
-        "node_modules/for-each": {
-            "version": "0.3.3",
-            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
-            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
-            "dev": true,
-            "dependencies": {
-                "is-callable": "^1.1.3"
-            }
-        },
-        "node_modules/foreground-child": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
-            "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
-            "dev": true,
-            "dependencies": {
-                "cross-spawn": "^7.0.0",
-                "signal-exit": "^4.0.1"
-            },
-            "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
-        "node_modules/foreground-child/node_modules/signal-exit": {
-            "version": "4.1.0",
-            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
-            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
-            "dev": true,
-            "engines": {
-                "node": ">=14"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
-        "node_modules/form-data": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
-            "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
-            "dev": true,
-            "dependencies": {
-                "asynckit": "^0.4.0",
-                "combined-stream": "^1.0.8",
-                "mime-types": "^2.1.12"
-            },
-            "engines": {
-                "node": ">= 6"
-            }
-        },
-        "node_modules/formik": {
-            "version": "2.4.6",
-            "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz",
-            "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==",
-            "funding": [
-                {
-                    "type": "individual",
-                    "url": "https://opencollective.com/formik"
-                }
-            ],
-            "dependencies": {
-                "@types/hoist-non-react-statics": "^3.3.1",
-                "deepmerge": "^2.1.1",
-                "hoist-non-react-statics": "^3.3.0",
-                "lodash": "^4.17.21",
-                "lodash-es": "^4.17.21",
-                "react-fast-compare": "^2.0.1",
-                "tiny-warning": "^1.0.2",
-                "tslib": "^2.0.0"
-            },
-            "peerDependencies": {
-                "react": ">=16.8.0"
-            }
-        },
-        "node_modules/forwarded": {
-            "version": "0.2.0",
-            "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
-            "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.6"
-            }
-        },
-        "node_modules/fraction.js": {
-            "version": "4.3.7",
-            "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
-            "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
-            "dev": true,
-            "engines": {
-                "node": "*"
-            },
-            "funding": {
-                "type": "patreon",
-                "url": "https://github.com/sponsors/rawify"
-            }
-        },
-        "node_modules/fresh": {
-            "version": "0.5.2",
-            "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
-            "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.6"
-            }
-        },
-        "node_modules/fs-extra": {
-            "version": "10.1.0",
-            "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
-            "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
-            "dev": true,
-            "dependencies": {
-                "graceful-fs": "^4.2.0",
-                "jsonfile": "^6.0.1",
-                "universalify": "^2.0.0"
-            },
-            "engines": {
-                "node": ">=12"
-            }
-        },
-        "node_modules/fs-monkey": {
-            "version": "1.0.6",
-            "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz",
-            "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==",
-            "dev": true
-        },
-        "node_modules/fs.realpath": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-            "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
-            "dev": true
-        },
-        "node_modules/fsevents": {
-            "version": "2.3.3",
-            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
-            "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-            "dev": true,
-            "hasInstallScript": true,
-            "optional": true,
-            "os": [
-                "darwin"
-            ],
-            "engines": {
-                "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-            }
-        },
-        "node_modules/function-bind": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
-            "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/function.prototype.name": {
-            "version": "1.1.6",
-            "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz",
-            "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "define-properties": "^1.2.0",
-                "es-abstract": "^1.22.1",
-                "functions-have-names": "^1.2.3"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/functions-have-names": {
-            "version": "1.2.3",
-            "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
-            "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
-            "dev": true,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/fuzzy-search": {
-            "version": "3.2.1",
-            "resolved": "https://registry.npmjs.org/fuzzy-search/-/fuzzy-search-3.2.1.tgz",
-            "integrity": "sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg=="
-        },
-        "node_modules/gensync": {
-            "version": "1.0.0-beta.2",
-            "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
-            "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
-            "dev": true,
-            "engines": {
-                "node": ">=6.9.0"
-            }
-        },
-        "node_modules/get-caller-file": {
-            "version": "2.0.5",
-            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
-            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-            "dev": true,
-            "engines": {
-                "node": "6.* || 8.* || >= 10.*"
-            }
-        },
-        "node_modules/get-intrinsic": {
-            "version": "1.2.4",
-            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
-            "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
-            "dependencies": {
-                "es-errors": "^1.3.0",
-                "function-bind": "^1.1.2",
-                "has-proto": "^1.0.1",
-                "has-symbols": "^1.0.3",
-                "hasown": "^2.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/get-package-type": {
-            "version": "0.1.0",
-            "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
-            "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
-            "dev": true,
-            "engines": {
-                "node": ">=8.0.0"
-            }
-        },
-        "node_modules/get-stream": {
-            "version": "6.0.1",
-            "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
-            "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/get-symbol-description": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz",
-            "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.5",
-                "es-errors": "^1.3.0",
-                "get-intrinsic": "^1.2.4"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/glob": {
-            "version": "7.2.3",
-            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
-            "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
-            "deprecated": "Glob versions prior to v9 are no longer supported",
-            "dev": true,
-            "dependencies": {
-                "fs.realpath": "^1.0.0",
-                "inflight": "^1.0.4",
-                "inherits": "2",
-                "minimatch": "^3.1.1",
-                "once": "^1.3.0",
-                "path-is-absolute": "^1.0.0"
-            },
-            "engines": {
-                "node": "*"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            }
-        },
-        "node_modules/glob-parent": {
-            "version": "6.0.2",
-            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
-            "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
-            "dev": true,
-            "dependencies": {
-                "is-glob": "^4.0.3"
-            },
-            "engines": {
-                "node": ">=10.13.0"
-            }
-        },
-        "node_modules/glob-to-regexp": {
-            "version": "0.4.1",
-            "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
-            "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
-            "dev": true
-        },
-        "node_modules/globals": {
-            "version": "11.12.0",
-            "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
-            "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
-            "dev": true,
-            "engines": {
-                "node": ">=4"
-            }
-        },
-        "node_modules/globalthis": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
-            "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
-            "dev": true,
-            "dependencies": {
-                "define-properties": "^1.2.1",
-                "gopd": "^1.0.1"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/globby": {
-            "version": "10.0.2",
-            "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz",
-            "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==",
-            "dev": true,
-            "dependencies": {
-                "@types/glob": "^7.1.1",
-                "array-union": "^2.1.0",
-                "dir-glob": "^3.0.1",
-                "fast-glob": "^3.0.3",
-                "glob": "^7.1.3",
-                "ignore": "^5.1.1",
-                "merge2": "^1.2.3",
-                "slash": "^3.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/gopd": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
-            "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
-            "dependencies": {
-                "get-intrinsic": "^1.1.3"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/graceful-fs": {
-            "version": "4.2.11",
-            "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
-            "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
-            "dev": true
-        },
-        "node_modules/graphemer": {
-            "version": "1.4.0",
-            "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
-            "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
-            "dev": true
-        },
-        "node_modules/growly": {
-            "version": "1.3.0",
-            "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
-            "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==",
-            "dev": true
-        },
-        "node_modules/handle-thing": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
-            "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==",
-            "dev": true
-        },
-        "node_modules/has-bigints": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
-            "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==",
-            "dev": true,
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/has-flag": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
-            "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
-            "dev": true,
-            "engines": {
-                "node": ">=4"
-            }
-        },
-        "node_modules/has-property-descriptors": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
-            "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
-            "dependencies": {
-                "es-define-property": "^1.0.0"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/has-proto": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
-            "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/has-symbols": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
-            "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/has-tostringtag": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
-            "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
-            "dev": true,
-            "dependencies": {
-                "has-symbols": "^1.0.3"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/hash-base": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz",
-            "integrity": "sha512-EeeoJKjTyt868liAlVmcv2ZsUfGHlE3Q+BICOXcZiwN3osr5Q/zFGYmTJpoIzuaSTAwndFy+GqhEwlU4L3j4Ow==",
-            "dev": true,
-            "dependencies": {
-                "inherits": "^2.0.1",
-                "safe-buffer": "^5.0.1"
-            },
-            "engines": {
-                "node": ">=4"
-            }
-        },
-        "node_modules/hash-sum": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz",
-            "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==",
-            "dev": true
-        },
-        "node_modules/hash.js": {
-            "version": "1.1.7",
-            "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
-            "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
-            "dev": true,
-            "dependencies": {
-                "inherits": "^2.0.3",
-                "minimalistic-assert": "^1.0.1"
-            }
-        },
-        "node_modules/hasown": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
-            "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
-            "dependencies": {
-                "function-bind": "^1.1.2"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            }
-        },
-        "node_modules/he": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
-            "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
-            "dev": true,
-            "bin": {
-                "he": "bin/he"
-            }
-        },
-        "node_modules/hmac-drbg": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
-            "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
-            "dev": true,
-            "dependencies": {
-                "hash.js": "^1.0.3",
-                "minimalistic-assert": "^1.0.0",
-                "minimalistic-crypto-utils": "^1.0.1"
-            }
-        },
-        "node_modules/hoist-non-react-statics": {
-            "version": "3.3.2",
-            "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
-            "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
-            "dependencies": {
-                "react-is": "^16.7.0"
-            }
-        },
-        "node_modules/hpack.js": {
-            "version": "2.1.6",
-            "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
-            "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==",
-            "dev": true,
-            "dependencies": {
-                "inherits": "^2.0.1",
-                "obuf": "^1.0.0",
-                "readable-stream": "^2.0.1",
-                "wbuf": "^1.1.0"
-            }
-        },
-        "node_modules/html-encoding-sniffer": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
-            "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==",
-            "dev": true,
-            "dependencies": {
-                "whatwg-encoding": "^2.0.0"
-            },
-            "engines": {
-                "node": ">=12"
-            }
-        },
-        "node_modules/html-entities": {
-            "version": "2.5.2",
-            "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz",
-            "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/mdevils"
-                },
-                {
-                    "type": "patreon",
-                    "url": "https://patreon.com/mdevils"
-                }
-            ]
-        },
-        "node_modules/html-escaper": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
-            "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
-            "dev": true
-        },
-        "node_modules/html-loader": {
-            "version": "1.3.2",
-            "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-1.3.2.tgz",
-            "integrity": "sha512-DEkUwSd0sijK5PF3kRWspYi56XP7bTNkyg5YWSzBdjaSDmvCufep5c4Vpb3PBf6lUL0YPtLwBfy9fL0t5hBAGA==",
-            "dev": true,
-            "dependencies": {
-                "html-minifier-terser": "^5.1.1",
-                "htmlparser2": "^4.1.0",
-                "loader-utils": "^2.0.0",
-                "schema-utils": "^3.0.0"
-            },
-            "engines": {
-                "node": ">= 10.13.0"
-            },
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/webpack"
-            },
-            "peerDependencies": {
-                "webpack": "^4.0.0 || ^5.0.0"
-            }
-        },
-        "node_modules/html-loader/node_modules/schema-utils": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
-            "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
-            "dev": true,
-            "dependencies": {
-                "@types/json-schema": "^7.0.8",
-                "ajv": "^6.12.5",
-                "ajv-keywords": "^3.5.2"
-            },
-            "engines": {
-                "node": ">= 10.13.0"
-            },
-            "funding": {
-                "type": "opencollective",
-                "url": "https://opencollective.com/webpack"
-            }
-        },
-        "node_modules/html-minifier-terser": {
-            "version": "5.1.1",
-            "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
-            "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
-            "dev": true,
-            "dependencies": {
-                "camel-case": "^4.1.1",
-                "clean-css": "^4.2.3",
-                "commander": "^4.1.1",
-                "he": "^1.2.0",
-                "param-case": "^3.0.3",
-                "relateurl": "^0.2.7",
-                "terser": "^4.6.3"
-            },
-            "bin": {
-                "html-minifier-terser": "cli.js"
-            },
-            "engines": {
-                "node": ">=6"
-            }
-        },
-        "node_modules/html-minifier-terser/node_modules/clean-css": {
-            "version": "4.2.4",
-            "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
-            "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
-            "dev": true,
-            "dependencies": {
-                "source-map": "~0.6.0"
-            },
-            "engines": {
-                "node": ">= 4.0"
-            }
-        },
-        "node_modules/html-minifier-terser/node_modules/commander": {
-            "version": "4.1.1",
-            "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
-            "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
-            "dev": true,
-            "engines": {
-                "node": ">= 6"
-            }
-        },
-        "node_modules/html-minifier-terser/node_modules/terser": {
-            "version": "4.8.1",
-            "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
-            "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
-            "dev": true,
-            "dependencies": {
-                "commander": "^2.20.0",
-                "source-map": "~0.6.1",
-                "source-map-support": "~0.5.12"
-            },
-            "bin": {
-                "terser": "bin/terser"
-            },
-            "engines": {
-                "node": ">=6.0.0"
-            }
-        },
-        "node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": {
-            "version": "2.20.3",
-            "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
-            "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
-            "dev": true
-        },
-        "node_modules/html-parse-stringify": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
-            "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
-            "dependencies": {
-                "void-elements": "3.1.0"
-            }
-        },
-        "node_modules/htmlparser2": {
-            "version": "4.1.0",
-            "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz",
-            "integrity": "sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==",
-            "dev": true,
-            "dependencies": {
-                "domelementtype": "^2.0.1",
-                "domhandler": "^3.0.0",
-                "domutils": "^2.0.0",
-                "entities": "^2.0.0"
-            }
-        },
-        "node_modules/http-deceiver": {
-            "version": "1.2.7",
-            "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
-            "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==",
-            "dev": true
-        },
-        "node_modules/http-errors": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
-            "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
-            "dev": true,
-            "dependencies": {
-                "depd": "2.0.0",
-                "inherits": "2.0.4",
-                "setprototypeof": "1.2.0",
-                "statuses": "2.0.1",
-                "toidentifier": "1.0.1"
-            },
-            "engines": {
-                "node": ">= 0.8"
-            }
-        },
-        "node_modules/http-parser-js": {
-            "version": "0.5.8",
-            "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz",
-            "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==",
-            "dev": true
-        },
-        "node_modules/http-proxy": {
-            "version": "1.18.1",
-            "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
-            "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
-            "dev": true,
-            "dependencies": {
-                "eventemitter3": "^4.0.0",
-                "follow-redirects": "^1.0.0",
-                "requires-port": "^1.0.0"
-            },
-            "engines": {
-                "node": ">=8.0.0"
-            }
-        },
-        "node_modules/http-proxy-agent": {
-            "version": "5.0.0",
-            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
-            "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
-            "dev": true,
-            "dependencies": {
-                "@tootallnate/once": "2",
-                "agent-base": "6",
-                "debug": "4"
-            },
-            "engines": {
-                "node": ">= 6"
-            }
-        },
-        "node_modules/http-proxy-middleware": {
-            "version": "2.0.6",
-            "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
-            "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==",
-            "dev": true,
-            "dependencies": {
-                "@types/http-proxy": "^1.17.8",
-                "http-proxy": "^1.18.1",
-                "is-glob": "^4.0.1",
-                "is-plain-obj": "^3.0.0",
-                "micromatch": "^4.0.2"
-            },
-            "engines": {
-                "node": ">=12.0.0"
-            },
-            "peerDependencies": {
-                "@types/express": "^4.17.13"
-            },
-            "peerDependenciesMeta": {
-                "@types/express": {
-                    "optional": true
-                }
-            }
-        },
-        "node_modules/https-browserify": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
-            "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==",
-            "dev": true
-        },
-        "node_modules/https-proxy-agent": {
-            "version": "5.0.1",
-            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
-            "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
-            "dev": true,
-            "dependencies": {
-                "agent-base": "6",
-                "debug": "4"
-            },
-            "engines": {
-                "node": ">= 6"
-            }
-        },
-        "node_modules/human-signals": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
-            "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
-            "dev": true,
-            "engines": {
-                "node": ">=10.17.0"
-            }
-        },
-        "node_modules/i18next": {
-            "version": "23.14.0",
-            "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.14.0.tgz",
-            "integrity": "sha512-Y5GL4OdA8IU2geRrt2+Uc1iIhsjICdHZzT9tNwQ3TVqdNzgxHToGCKf/TPRP80vTCAP6svg2WbbJL+Gx5MFQVA==",
-            "funding": [
-                {
-                    "type": "individual",
-                    "url": "https://locize.com"
-                },
-                {
-                    "type": "individual",
-                    "url": "https://locize.com/i18next.html"
-                },
-                {
-                    "type": "individual",
-                    "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
-                }
-            ],
-            "dependencies": {
-                "@babel/runtime": "^7.23.2"
-            }
-        },
-        "node_modules/i18next-browser-languagedetector": {
-            "version": "8.0.0",
-            "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz",
-            "integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==",
-            "dependencies": {
-                "@babel/runtime": "^7.23.2"
-            }
-        },
-        "node_modules/iconv-lite": {
-            "version": "0.4.24",
-            "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
-            "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
-            "dev": true,
-            "dependencies": {
-                "safer-buffer": ">= 2.1.2 < 3"
-            },
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/icss-utils": {
-            "version": "5.1.0",
-            "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
-            "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
-            "dev": true,
-            "engines": {
-                "node": "^10 || ^12 || >= 14"
-            },
-            "peerDependencies": {
-                "postcss": "^8.1.0"
-            }
-        },
-        "node_modules/ieee754": {
-            "version": "1.2.1",
-            "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-            "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "github",
-                    "url": "https://github.com/sponsors/feross"
-                },
-                {
-                    "type": "patreon",
-                    "url": "https://www.patreon.com/feross"
-                },
-                {
-                    "type": "consulting",
-                    "url": "https://feross.org/support"
-                }
-            ]
-        },
-        "node_modules/ignore": {
-            "version": "5.3.2",
-            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
-            "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
-            "dev": true,
-            "engines": {
-                "node": ">= 4"
-            }
-        },
-        "node_modules/imagemin": {
-            "version": "7.0.1",
-            "resolved": "https://registry.npmjs.org/imagemin/-/imagemin-7.0.1.tgz",
-            "integrity": "sha512-33AmZ+xjZhg2JMCe+vDf6a9mzWukE7l+wAtesjE7KyteqqKjzxv7aVQeWnul1Ve26mWvEQqyPwl0OctNBfSR9w==",
-            "dev": true,
-            "dependencies": {
-                "file-type": "^12.0.0",
-                "globby": "^10.0.0",
-                "graceful-fs": "^4.2.2",
-                "junk": "^3.1.0",
-                "make-dir": "^3.0.0",
-                "p-pipe": "^3.0.0",
-                "replace-ext": "^1.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/imagemin/node_modules/make-dir": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
-            "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
-            "dev": true,
-            "dependencies": {
-                "semver": "^6.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/img-loader": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/img-loader/-/img-loader-4.0.0.tgz",
-            "integrity": "sha512-UwRcPQdwdOyEHyCxe1V9s9YFwInwEWCpoO+kJGfIqDrBDqA8jZUsEZTxQ0JteNPGw/Gupmwesk2OhLTcnw6tnQ==",
-            "dev": true,
-            "dependencies": {
-                "loader-utils": "^1.1.0"
-            },
-            "engines": {
-                "node": ">=12"
-            },
-            "peerDependencies": {
-                "imagemin": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
-            }
-        },
-        "node_modules/img-loader/node_modules/json5": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
-            "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
-            "dev": true,
-            "dependencies": {
-                "minimist": "^1.2.0"
-            },
-            "bin": {
-                "json5": "lib/cli.js"
-            }
-        },
-        "node_modules/img-loader/node_modules/loader-utils": {
-            "version": "1.4.2",
-            "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
-            "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
-            "dev": true,
-            "dependencies": {
-                "big.js": "^5.2.2",
-                "emojis-list": "^3.0.0",
-                "json5": "^1.0.1"
-            },
-            "engines": {
-                "node": ">=4.0.0"
-            }
-        },
-        "node_modules/immediate": {
-            "version": "3.0.6",
-            "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
-            "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
-        },
-        "node_modules/immutable": {
-            "version": "4.3.7",
-            "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
-            "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
-            "dev": true
-        },
-        "node_modules/import-fresh": {
-            "version": "3.3.0",
-            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
-            "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
-            "dev": true,
-            "dependencies": {
-                "parent-module": "^1.0.0",
-                "resolve-from": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=6"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/import-fresh/node_modules/resolve-from": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
-            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
-            "dev": true,
-            "engines": {
-                "node": ">=4"
-            }
-        },
-        "node_modules/import-local": {
-            "version": "3.2.0",
-            "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
-            "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
-            "dev": true,
-            "dependencies": {
-                "pkg-dir": "^4.2.0",
-                "resolve-cwd": "^3.0.0"
-            },
-            "bin": {
-                "import-local-fixture": "fixtures/cli.js"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/imurmurhash": {
-            "version": "0.1.4",
-            "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
-            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.8.19"
-            }
-        },
-        "node_modules/indent-string": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
-            "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/inflight": {
-            "version": "1.0.6",
-            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-            "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
-            "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
-            "dev": true,
-            "dependencies": {
-                "once": "^1.3.0",
-                "wrappy": "1"
-            }
-        },
-        "node_modules/inherits": {
-            "version": "2.0.4",
-            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-            "dev": true
-        },
-        "node_modules/internal-slot": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz",
-            "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==",
-            "dev": true,
-            "dependencies": {
-                "es-errors": "^1.3.0",
-                "hasown": "^2.0.0",
-                "side-channel": "^1.0.4"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            }
-        },
-        "node_modules/internmap": {
-            "version": "2.0.3",
-            "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
-            "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
-            "engines": {
-                "node": ">=12"
-            }
-        },
-        "node_modules/interpret": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
-            "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.10"
-            }
-        },
-        "node_modules/invariant": {
-            "version": "2.2.4",
-            "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
-            "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
-            "dependencies": {
-                "loose-envify": "^1.0.0"
-            }
-        },
-        "node_modules/ipaddr.js": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
-            "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
-            "dev": true,
-            "engines": {
-                "node": ">= 10"
-            }
-        },
-        "node_modules/is-arguments": {
-            "version": "1.1.1",
-            "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
-            "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-array-buffer": {
-            "version": "3.0.4",
-            "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
-            "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "get-intrinsic": "^1.2.1"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-arrayish": {
-            "version": "0.2.1",
-            "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
-            "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
-            "dev": true
-        },
-        "node_modules/is-async-function": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz",
-            "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==",
-            "dev": true,
-            "dependencies": {
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-bigint": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
-            "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
-            "dev": true,
-            "dependencies": {
-                "has-bigints": "^1.0.1"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-binary-path": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
-            "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
-            "dev": true,
-            "dependencies": {
-                "binary-extensions": "^2.0.0"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/is-boolean-object": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
-            "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-buffer": {
-            "version": "1.1.6",
-            "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
-            "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
-            "dev": true
-        },
-        "node_modules/is-callable": {
-            "version": "1.2.7",
-            "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
-            "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-core-module": {
-            "version": "2.15.1",
-            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
-            "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
-            "dev": true,
-            "dependencies": {
-                "hasown": "^2.0.2"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-data-view": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz",
-            "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==",
-            "dev": true,
-            "dependencies": {
-                "is-typed-array": "^1.1.13"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-date-object": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
-            "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
-            "dev": true,
-            "dependencies": {
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-docker": {
-            "version": "2.2.1",
-            "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
-            "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
-            "dev": true,
-            "bin": {
-                "is-docker": "cli.js"
-            },
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/is-extglob": {
-            "version": "2.1.1",
-            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
-            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/is-finalizationregistry": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz",
-            "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-fullwidth-code-point": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
-            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/is-generator-fn": {
-            "version": "2.1.0",
-            "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
-            "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=6"
-            }
-        },
-        "node_modules/is-generator-function": {
-            "version": "1.0.10",
-            "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
-            "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==",
-            "dev": true,
-            "dependencies": {
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-glob": {
-            "version": "4.0.3",
-            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
-            "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
-            "dev": true,
-            "dependencies": {
-                "is-extglob": "^2.1.1"
-            },
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/is-map": {
-            "version": "2.0.3",
-            "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
-            "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-negative-zero": {
-            "version": "2.0.3",
-            "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
-            "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-number": {
-            "version": "7.0.0",
-            "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
-            "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.12.0"
-            }
-        },
-        "node_modules/is-number-object": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz",
-            "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==",
-            "dev": true,
-            "dependencies": {
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-path-inside": {
-            "version": "3.0.3",
-            "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
-            "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/is-plain-obj": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
-            "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/is-plain-object": {
-            "version": "2.0.4",
-            "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
-            "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
-            "dev": true,
-            "dependencies": {
-                "isobject": "^3.0.1"
-            },
-            "engines": {
-                "node": ">=0.10.0"
-            }
-        },
-        "node_modules/is-potential-custom-element-name": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
-            "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
-            "dev": true
-        },
-        "node_modules/is-regex": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
-            "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2",
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-set": {
-            "version": "2.0.3",
-            "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
-            "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-shared-array-buffer": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz",
-            "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.7"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-stream": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
-            "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
-        "node_modules/is-string": {
-            "version": "1.0.7",
-            "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
-            "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
-            "dev": true,
-            "dependencies": {
-                "has-tostringtag": "^1.0.0"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-symbol": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
-            "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
-            "dev": true,
-            "dependencies": {
-                "has-symbols": "^1.0.2"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-typed-array": {
-            "version": "1.1.13",
-            "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz",
-            "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==",
-            "dev": true,
-            "dependencies": {
-                "which-typed-array": "^1.1.14"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-weakmap": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
-            "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
-            "dev": true,
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-weakref": {
-            "version": "1.0.2",
-            "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
-            "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.2"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-weakset": {
-            "version": "2.0.3",
-            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz",
-            "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==",
-            "dev": true,
-            "dependencies": {
-                "call-bind": "^1.0.7",
-                "get-intrinsic": "^1.2.4"
-            },
-            "engines": {
-                "node": ">= 0.4"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/ljharb"
-            }
-        },
-        "node_modules/is-wsl": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
-            "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+        "node_modules/fast-glob/node_modules/glob-parent": {
+            "version": "5.1.2",
+            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+            "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
             "dev": true,
             "dependencies": {
-                "is-docker": "^2.0.0"
+                "is-glob": "^4.0.1"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">= 6"
             }
         },
-        "node_modules/isarray": {
-            "version": "1.0.0",
-            "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
-            "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+        "node_modules/fast-json-stable-stringify": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+            "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
             "dev": true
         },
-        "node_modules/isexe": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+        "node_modules/fast-levenshtein": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+            "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
             "dev": true
         },
-        "node_modules/isobject": {
+        "node_modules/fast-uri": {
             "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
-            "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
-            "dev": true,
-            "engines": {
-                "node": ">=0.10.0"
-            }
+            "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz",
+            "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==",
+            "dev": true
         },
-        "node_modules/istanbul-lib-coverage": {
-            "version": "3.2.2",
-            "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
-            "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+        "node_modules/fastest-levenshtein": {
+            "version": "1.0.16",
+            "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
+            "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
             "dev": true,
             "engines": {
-                "node": ">=8"
+                "node": ">= 4.9.1"
             }
         },
-        "node_modules/istanbul-lib-instrument": {
-            "version": "5.2.1",
-            "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
-            "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
+        "node_modules/fastq": {
+            "version": "1.17.1",
+            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+            "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
             "dev": true,
             "dependencies": {
-                "@babel/core": "^7.12.3",
-                "@babel/parser": "^7.14.7",
-                "@istanbuljs/schema": "^0.1.2",
-                "istanbul-lib-coverage": "^3.2.0",
-                "semver": "^6.3.0"
-            },
-            "engines": {
-                "node": ">=8"
+                "reusify": "^1.0.4"
             }
         },
-        "node_modules/istanbul-lib-report": {
-            "version": "3.0.1",
-            "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
-            "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+        "node_modules/faye-websocket": {
+            "version": "0.11.4",
+            "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
+            "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
             "dev": true,
             "dependencies": {
-                "istanbul-lib-coverage": "^3.0.0",
-                "make-dir": "^4.0.0",
-                "supports-color": "^7.1.0"
+                "websocket-driver": ">=0.5.1"
             },
             "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/istanbul-lib-report/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
+                "node": ">=0.8.0"
             }
         },
-        "node_modules/istanbul-lib-report/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/file-entry-cache": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+            "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
+                "flat-cache": "^3.0.4"
             },
             "engines": {
-                "node": ">=8"
+                "node": "^10.12.0 || >=12.0.0"
             }
         },
-        "node_modules/istanbul-lib-source-maps": {
-            "version": "4.0.1",
-            "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
-            "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+        "node_modules/file-loader": {
+            "version": "6.2.0",
+            "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
+            "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
             "dev": true,
             "dependencies": {
-                "debug": "^4.1.1",
-                "istanbul-lib-coverage": "^3.0.0",
-                "source-map": "^0.6.1"
+                "loader-utils": "^2.0.0",
+                "schema-utils": "^3.0.0"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">= 10.13.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/webpack"
+            },
+            "peerDependencies": {
+                "webpack": "^4.0.0 || ^5.0.0"
             }
         },
-        "node_modules/istanbul-reports": {
-            "version": "3.1.7",
-            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
-            "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+        "node_modules/file-loader/node_modules/schema-utils": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
+            "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
             "dev": true,
             "dependencies": {
-                "html-escaper": "^2.0.0",
-                "istanbul-lib-report": "^3.0.0"
+                "@types/json-schema": "^7.0.8",
+                "ajv": "^6.12.5",
+                "ajv-keywords": "^3.5.2"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">= 10.13.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/webpack"
             }
         },
-        "node_modules/iterator.prototype": {
-            "version": "1.1.2",
-            "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz",
-            "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==",
-            "dev": true,
-            "dependencies": {
-                "define-properties": "^1.2.1",
-                "get-intrinsic": "^1.2.1",
-                "has-symbols": "^1.0.3",
-                "reflect.getprototypeof": "^1.0.4",
-                "set-function-name": "^2.0.1"
-            }
+        "node_modules/file-saver": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+            "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
         },
-        "node_modules/jackspeak": {
-            "version": "3.4.3",
-            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
-            "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+        "node_modules/file-type": {
+            "version": "12.4.2",
+            "resolved": "https://registry.npmjs.org/file-type/-/file-type-12.4.2.tgz",
+            "integrity": "sha512-UssQP5ZgIOKelfsaB5CuGAL+Y+q7EmONuiwF3N5HAH0t27rvrttgi6Ra9k/+DVaY9UF6+ybxu5pOXLUdA8N7Vg==",
             "dev": true,
-            "dependencies": {
-                "@isaacs/cliui": "^8.0.2"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/isaacs"
-            },
-            "optionalDependencies": {
-                "@pkgjs/parseargs": "^0.11.0"
+            "engines": {
+                "node": ">=8"
             }
         },
-        "node_modules/jest": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
-            "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
+        "node_modules/fill-range": {
+            "version": "7.1.1",
+            "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+            "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
             "dev": true,
             "dependencies": {
-                "@jest/core": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "import-local": "^3.0.2",
-                "jest-cli": "^29.7.0"
-            },
-            "bin": {
-                "jest": "bin/jest.js"
+                "to-regex-range": "^5.0.1"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            },
-            "peerDependencies": {
-                "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
-            },
-            "peerDependenciesMeta": {
-                "node-notifier": {
-                    "optional": true
-                }
+                "node": ">=8"
             }
         },
-        "node_modules/jest-changed-files": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz",
-            "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==",
+        "node_modules/finalhandler": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
+            "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==",
             "dev": true,
             "dependencies": {
-                "execa": "^5.0.0",
-                "jest-util": "^29.7.0",
-                "p-limit": "^3.1.0"
+                "debug": "2.6.9",
+                "encodeurl": "~1.0.2",
+                "escape-html": "~1.0.3",
+                "on-finished": "2.4.1",
+                "parseurl": "~1.3.3",
+                "statuses": "2.0.1",
+                "unpipe": "~1.0.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">= 0.8"
             }
         },
-        "node_modules/jest-circus": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
-            "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==",
+        "node_modules/finalhandler/node_modules/debug": {
+            "version": "2.6.9",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+            "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
             "dev": true,
             "dependencies": {
-                "@jest/environment": "^29.7.0",
-                "@jest/expect": "^29.7.0",
-                "@jest/test-result": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "chalk": "^4.0.0",
-                "co": "^4.6.0",
-                "dedent": "^1.0.0",
-                "is-generator-fn": "^2.0.0",
-                "jest-each": "^29.7.0",
-                "jest-matcher-utils": "^29.7.0",
-                "jest-message-util": "^29.7.0",
-                "jest-runtime": "^29.7.0",
-                "jest-snapshot": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "p-limit": "^3.1.0",
-                "pretty-format": "^29.7.0",
-                "pure-rand": "^6.0.0",
-                "slash": "^3.0.0",
-                "stack-utils": "^2.0.3"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/jest-circus/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+                "ms": "2.0.0"
+            }
+        },
+        "node_modules/finalhandler/node_modules/ms": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+            "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+            "dev": true
+        },
+        "node_modules/find-cache-dir": {
+            "version": "3.3.2",
+            "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
+            "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "commondir": "^1.0.1",
+                "make-dir": "^3.0.2",
+                "pkg-dir": "^4.1.0"
             },
             "engines": {
                 "node": ">=8"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/avajs/find-cache-dir?sponsor=1"
             }
         },
-        "node_modules/jest-circus/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/find-cache-dir/node_modules/make-dir": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+            "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
             "dev": true,
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "semver": "^6.0.0"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">=8"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-circus/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/find-up": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+            "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "locate-path": "^5.0.0",
+                "path-exists": "^4.0.0"
             },
-            "engines": {
-                "node": ">=7.0.0"
-            }
-        },
-        "node_modules/jest-circus/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/jest-circus/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/jest-circus/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
-            "dev": true,
-            "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/jest-circus/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+        "node_modules/flat": {
+            "version": "5.0.2",
+            "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+            "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
             "dev": true,
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            "bin": {
+                "flat": "cli.js"
             }
         },
-        "node_modules/jest-circus/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-            "dev": true
-        },
-        "node_modules/jest-circus/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/flat-cache": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+            "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
+                "flatted": "^3.2.9",
+                "keyv": "^4.5.3",
+                "rimraf": "^3.0.2"
             },
             "engines": {
-                "node": ">=8"
+                "node": "^10.12.0 || >=12.0.0"
             }
         },
-        "node_modules/jest-cli": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz",
-            "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==",
+        "node_modules/flatted": {
+            "version": "3.3.1",
+            "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
+            "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+            "dev": true
+        },
+        "node_modules/follow-redirects": {
+            "version": "1.15.6",
+            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
+            "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
             "dev": true,
-            "dependencies": {
-                "@jest/core": "^29.7.0",
-                "@jest/test-result": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "chalk": "^4.0.0",
-                "create-jest": "^29.7.0",
-                "exit": "^0.1.2",
-                "import-local": "^3.0.2",
-                "jest-config": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "jest-validate": "^29.7.0",
-                "yargs": "^17.3.1"
-            },
-            "bin": {
-                "jest": "bin/jest.js"
-            },
+            "funding": [
+                {
+                    "type": "individual",
+                    "url": "https://github.com/sponsors/RubenVerborgh"
+                }
+            ],
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            },
-            "peerDependencies": {
-                "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+                "node": ">=4.0"
             },
             "peerDependenciesMeta": {
-                "node-notifier": {
+                "debug": {
                     "optional": true
                 }
             }
         },
-        "node_modules/jest-cli/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/for-each": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
+            "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "is-callable": "^1.1.3"
+            }
+        },
+        "node_modules/foreground-child": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
+            "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
+            "dev": true,
+            "dependencies": {
+                "cross-spawn": "^7.0.0",
+                "signal-exit": "^4.0.1"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">=14"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/jest-cli/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/foreground-child/node_modules/signal-exit": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
             "engines": {
-                "node": ">=10"
+                "node": ">=14"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/jest-cli/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/form-data": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+            "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "asynckit": "^0.4.0",
+                "combined-stream": "^1.0.8",
+                "mime-types": "^2.1.12"
             },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">= 6"
             }
         },
-        "node_modules/jest-cli/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
+        "node_modules/formik": {
+            "version": "2.4.6",
+            "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz",
+            "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==",
+            "funding": [
+                {
+                    "type": "individual",
+                    "url": "https://opencollective.com/formik"
+                }
+            ],
+            "dependencies": {
+                "@types/hoist-non-react-statics": "^3.3.1",
+                "deepmerge": "^2.1.1",
+                "hoist-non-react-statics": "^3.3.0",
+                "lodash": "^4.17.21",
+                "lodash-es": "^4.17.21",
+                "react-fast-compare": "^2.0.1",
+                "tiny-warning": "^1.0.2",
+                "tslib": "^2.0.0"
+            },
+            "peerDependencies": {
+                "react": ">=16.8.0"
+            }
         },
-        "node_modules/jest-cli/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/forwarded": {
+            "version": "0.2.0",
+            "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+            "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
             "dev": true,
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.6"
             }
         },
-        "node_modules/jest-cli/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/fraction.js": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+            "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
             "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
+            "engines": {
+                "node": "*"
             },
+            "funding": {
+                "type": "patreon",
+                "url": "https://github.com/sponsors/rawify"
+            }
+        },
+        "node_modules/fresh": {
+            "version": "0.5.2",
+            "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+            "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+            "dev": true,
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.6"
             }
         },
-        "node_modules/jest-config": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz",
-            "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==",
+        "node_modules/fs-extra": {
+            "version": "10.1.0",
+            "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+            "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
             "dev": true,
             "dependencies": {
-                "@babel/core": "^7.11.6",
-                "@jest/test-sequencer": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "babel-jest": "^29.7.0",
-                "chalk": "^4.0.0",
-                "ci-info": "^3.2.0",
-                "deepmerge": "^4.2.2",
-                "glob": "^7.1.3",
-                "graceful-fs": "^4.2.9",
-                "jest-circus": "^29.7.0",
-                "jest-environment-node": "^29.7.0",
-                "jest-get-type": "^29.6.3",
-                "jest-regex-util": "^29.6.3",
-                "jest-resolve": "^29.7.0",
-                "jest-runner": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "jest-validate": "^29.7.0",
-                "micromatch": "^4.0.4",
-                "parse-json": "^5.2.0",
-                "pretty-format": "^29.7.0",
-                "slash": "^3.0.0",
-                "strip-json-comments": "^3.1.1"
+                "graceful-fs": "^4.2.0",
+                "jsonfile": "^6.0.1",
+                "universalify": "^2.0.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            },
-            "peerDependencies": {
-                "@types/node": "*",
-                "ts-node": ">=9.0.0"
-            },
-            "peerDependenciesMeta": {
-                "@types/node": {
-                    "optional": true
-                },
-                "ts-node": {
-                    "optional": true
-                }
+                "node": ">=12"
             }
         },
-        "node_modules/jest-config/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/fs-monkey": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz",
+            "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==",
+            "dev": true
+        },
+        "node_modules/fs.realpath": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+            "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+            "dev": true
+        },
+        "node_modules/fsevents": {
+            "version": "2.3.3",
+            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+            "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
             "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
+            "hasInstallScript": true,
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
             "engines": {
-                "node": ">=8"
-            },
+                "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+            }
+        },
+        "node_modules/function-bind": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+            "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-config/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/function.prototype.name": {
+            "version": "1.1.6",
+            "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz",
+            "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==",
             "dev": true,
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "call-bind": "^1.0.2",
+                "define-properties": "^1.2.0",
+                "es-abstract": "^1.22.1",
+                "functions-have-names": "^1.2.3"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-config/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/functions-have-names": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+            "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
             "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
-            "engines": {
-                "node": ">=7.0.0"
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-config/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
+        "node_modules/fuzzy-search": {
+            "version": "3.2.1",
+            "resolved": "https://registry.npmjs.org/fuzzy-search/-/fuzzy-search-3.2.1.tgz",
+            "integrity": "sha512-vAcPiyomt1ioKAsAL2uxSABHJ4Ju/e4UeDM+g1OlR0vV4YhLGMNsdLNvZTpEDY4JCSt0E4hASCNM5t2ETtsbyg=="
         },
-        "node_modules/jest-config/node_modules/deepmerge": {
-            "version": "4.3.1",
-            "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
-            "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+        "node_modules/gensync": {
+            "version": "1.0.0-beta.2",
+            "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+            "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
             "dev": true,
             "engines": {
-                "node": ">=0.10.0"
+                "node": ">=6.9.0"
             }
         },
-        "node_modules/jest-config/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/get-caller-file": {
+            "version": "2.0.5",
+            "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+            "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
             "dev": true,
             "engines": {
-                "node": ">=8"
+                "node": "6.* || 8.* || >= 10.*"
             }
         },
-        "node_modules/jest-config/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
-            "dev": true,
+        "node_modules/get-intrinsic": {
+            "version": "1.2.4",
+            "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
+            "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
             "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
+                "es-errors": "^1.3.0",
+                "function-bind": "^1.1.2",
+                "has-proto": "^1.0.1",
+                "has-symbols": "^1.0.3",
+                "hasown": "^2.0.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/jest-config/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-config/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-            "dev": true
-        },
-        "node_modules/jest-config/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/get-stream": {
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+            "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
             "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
             "engines": {
-                "node": ">=8"
-            }
-        },
-        "node_modules/jest-diff": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
-            "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
-            "dev": true,
-            "dependencies": {
-                "chalk": "^4.0.0",
-                "diff-sequences": "^29.6.3",
-                "jest-get-type": "^29.6.3",
-                "pretty-format": "^29.7.0"
+                "node": ">=10"
             },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-diff/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/get-symbol-description": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz",
+            "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "call-bind": "^1.0.5",
+                "es-errors": "^1.3.0",
+                "get-intrinsic": "^1.2.4"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-diff/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/glob": {
+            "version": "7.2.3",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+            "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+            "deprecated": "Glob versions prior to v9 are no longer supported",
             "dev": true,
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "fs.realpath": "^1.0.0",
+                "inflight": "^1.0.4",
+                "inherits": "2",
+                "minimatch": "^3.1.1",
+                "once": "^1.3.0",
+                "path-is-absolute": "^1.0.0"
             },
             "engines": {
-                "node": ">=10"
+                "node": "*"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/jest-diff/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/glob-parent": {
+            "version": "6.0.2",
+            "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+            "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "is-glob": "^4.0.3"
             },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">=10.13.0"
             }
         },
-        "node_modules/jest-diff/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+        "node_modules/glob-to-regexp": {
+            "version": "0.4.1",
+            "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+            "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
             "dev": true
         },
-        "node_modules/jest-diff/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/globals": {
+            "version": "16.2.0",
+            "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz",
+            "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==",
             "dev": true,
+            "license": "MIT",
             "engines": {
-                "node": ">=8"
+                "node": ">=18"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-diff/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+        "node_modules/globalthis": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+            "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
             "dev": true,
             "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
+                "define-properties": "^1.2.1",
+                "gopd": "^1.0.1"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/jest-diff/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-diff/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-            "dev": true
-        },
-        "node_modules/jest-diff/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/globby": {
+            "version": "10.0.2",
+            "resolved": "https://registry.npmjs.org/globby/-/globby-10.0.2.tgz",
+            "integrity": "sha512-7dUi7RvCoT/xast/o/dLN53oqND4yk0nsHkhRgn9w65C4PofCLOoJ39iSOg+qVDdWQPIEj+eszMHQ+aLVwwQSg==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
+                "@types/glob": "^7.1.1",
+                "array-union": "^2.1.0",
+                "dir-glob": "^3.0.1",
+                "fast-glob": "^3.0.3",
+                "glob": "^7.1.3",
+                "ignore": "^5.1.1",
+                "merge2": "^1.2.3",
+                "slash": "^3.0.0"
             },
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/jest-docblock": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz",
-            "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==",
-            "dev": true,
+        "node_modules/gopd": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
+            "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
             "dependencies": {
-                "detect-newline": "^3.0.0"
+                "get-intrinsic": "^1.1.3"
             },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-each": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
-            "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
-            "dev": true,
-            "dependencies": {
-                "@jest/types": "^29.6.3",
-                "chalk": "^4.0.0",
-                "jest-get-type": "^29.6.3",
-                "jest-util": "^29.7.0",
-                "pretty-format": "^29.7.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
+        "node_modules/graceful-fs": {
+            "version": "4.2.11",
+            "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+            "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+            "dev": true
         },
-        "node_modules/jest-each/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/graphemer": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+            "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+            "dev": true
+        },
+        "node_modules/growly": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz",
+            "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==",
+            "dev": true
+        },
+        "node_modules/handle-thing": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
+            "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==",
+            "dev": true
+        },
+        "node_modules/has-bigints": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz",
+            "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==",
             "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
-            "engines": {
-                "node": ">=8"
-            },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-each/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-            "dev": true,
+        "node_modules/has-property-descriptors": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+            "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
-            "engines": {
-                "node": ">=10"
+                "es-define-property": "^1.0.0"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-each/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-            "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
+        "node_modules/has-proto": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
+            "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-each/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/jest-each/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
+        "node_modules/has-symbols": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+            "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-each/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+        "node_modules/has-tostringtag": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+            "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
             "dev": true,
             "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
+                "has-symbols": "^1.0.3"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-each/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+        "node_modules/hash-base": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz",
+            "integrity": "sha512-EeeoJKjTyt868liAlVmcv2ZsUfGHlE3Q+BICOXcZiwN3osr5Q/zFGYmTJpoIzuaSTAwndFy+GqhEwlU4L3j4Ow==",
             "dev": true,
-            "engines": {
-                "node": ">=10"
+            "dependencies": {
+                "inherits": "^2.0.1",
+                "safe-buffer": "^5.0.1"
             },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            "engines": {
+                "node": ">=4"
             }
         },
-        "node_modules/jest-each/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+        "node_modules/hash-sum": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-1.0.2.tgz",
+            "integrity": "sha512-fUs4B4L+mlt8/XAtSOGMUO1TXmAelItBPtJG7CyHJfYTdDjwisntGO2JQz7oUsatOY9o68+57eziUVNw/mRHmA==",
             "dev": true
         },
-        "node_modules/jest-each/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/hash.js": {
+            "version": "1.1.7",
+            "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+            "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=8"
+                "inherits": "^2.0.3",
+                "minimalistic-assert": "^1.0.1"
             }
         },
-        "node_modules/jest-environment-jsdom": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz",
-            "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==",
-            "dev": true,
+        "node_modules/hasown": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+            "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
             "dependencies": {
-                "@jest/environment": "^29.7.0",
-                "@jest/fake-timers": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/jsdom": "^20.0.0",
-                "@types/node": "*",
-                "jest-mock": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "jsdom": "^20.0.0"
+                "function-bind": "^1.1.2"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            },
-            "peerDependencies": {
-                "canvas": "^2.5.0"
-            },
-            "peerDependenciesMeta": {
-                "canvas": {
-                    "optional": true
-                }
+                "node": ">= 0.4"
+            }
+        },
+        "node_modules/he": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+            "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+            "dev": true,
+            "bin": {
+                "he": "bin/he"
             }
         },
-        "node_modules/jest-environment-node": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
-            "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==",
+        "node_modules/hmac-drbg": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+            "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
             "dev": true,
             "dependencies": {
-                "@jest/environment": "^29.7.0",
-                "@jest/fake-timers": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "jest-mock": "^29.7.0",
-                "jest-util": "^29.7.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "hash.js": "^1.0.3",
+                "minimalistic-assert": "^1.0.0",
+                "minimalistic-crypto-utils": "^1.0.1"
             }
         },
-        "node_modules/jest-get-type": {
-            "version": "29.6.3",
-            "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
-            "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
-            "dev": true,
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+        "node_modules/hoist-non-react-statics": {
+            "version": "3.3.2",
+            "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+            "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+            "dependencies": {
+                "react-is": "^16.7.0"
             }
         },
-        "node_modules/jest-haste-map": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz",
-            "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==",
+        "node_modules/hpack.js": {
+            "version": "2.1.6",
+            "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz",
+            "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==",
             "dev": true,
             "dependencies": {
-                "@jest/types": "^29.6.3",
-                "@types/graceful-fs": "^4.1.3",
-                "@types/node": "*",
-                "anymatch": "^3.0.3",
-                "fb-watchman": "^2.0.0",
-                "graceful-fs": "^4.2.9",
-                "jest-regex-util": "^29.6.3",
-                "jest-util": "^29.7.0",
-                "jest-worker": "^29.7.0",
-                "micromatch": "^4.0.4",
-                "walker": "^1.0.8"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            },
-            "optionalDependencies": {
-                "fsevents": "^2.3.2"
+                "inherits": "^2.0.1",
+                "obuf": "^1.0.0",
+                "readable-stream": "^2.0.1",
+                "wbuf": "^1.1.0"
             }
         },
-        "node_modules/jest-leak-detector": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
-            "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==",
+        "node_modules/html-encoding-sniffer": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+            "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "jest-get-type": "^29.6.3",
-                "pretty-format": "^29.7.0"
+                "whatwg-encoding": "^3.1.1"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=18"
             }
         },
-        "node_modules/jest-leak-detector/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+        "node_modules/html-entities": {
+            "version": "2.5.2",
+            "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz",
+            "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/mdevils"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://patreon.com/mdevils"
+                }
+            ]
+        },
+        "node_modules/html-loader": {
+            "version": "1.3.2",
+            "resolved": "https://registry.npmjs.org/html-loader/-/html-loader-1.3.2.tgz",
+            "integrity": "sha512-DEkUwSd0sijK5PF3kRWspYi56XP7bTNkyg5YWSzBdjaSDmvCufep5c4Vpb3PBf6lUL0YPtLwBfy9fL0t5hBAGA==",
             "dev": true,
+            "dependencies": {
+                "html-minifier-terser": "^5.1.1",
+                "htmlparser2": "^4.1.0",
+                "loader-utils": "^2.0.0",
+                "schema-utils": "^3.0.0"
+            },
             "engines": {
-                "node": ">=10"
+                "node": ">= 10.13.0"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "type": "opencollective",
+                "url": "https://opencollective.com/webpack"
+            },
+            "peerDependencies": {
+                "webpack": "^4.0.0 || ^5.0.0"
             }
         },
-        "node_modules/jest-leak-detector/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+        "node_modules/html-loader/node_modules/schema-utils": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
+            "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
             "dev": true,
             "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
+                "@types/json-schema": "^7.0.8",
+                "ajv": "^6.12.5",
+                "ajv-keywords": "^3.5.2"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">= 10.13.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/webpack"
             }
         },
-        "node_modules/jest-leak-detector/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-            "dev": true
-        },
-        "node_modules/jest-matcher-utils": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz",
-            "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==",
+        "node_modules/html-minifier-terser": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
+            "integrity": "sha512-ZPr5MNObqnV/T9akshPKbVgyOqLmy+Bxo7juKCfTfnjNniTAMdy4hz21YQqoofMBJD2kdREaqPPdThoR78Tgxg==",
             "dev": true,
             "dependencies": {
-                "chalk": "^4.0.0",
-                "jest-diff": "^29.7.0",
-                "jest-get-type": "^29.6.3",
-                "pretty-format": "^29.7.0"
+                "camel-case": "^4.1.1",
+                "clean-css": "^4.2.3",
+                "commander": "^4.1.1",
+                "he": "^1.2.0",
+                "param-case": "^3.0.3",
+                "relateurl": "^0.2.7",
+                "terser": "^4.6.3"
+            },
+            "bin": {
+                "html-minifier-terser": "cli.js"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=6"
             }
         },
-        "node_modules/jest-matcher-utils/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/html-minifier-terser/node_modules/clean-css": {
+            "version": "4.2.4",
+            "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz",
+            "integrity": "sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "source-map": "~0.6.0"
             },
             "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "node": ">= 4.0"
             }
         },
-        "node_modules/jest-matcher-utils/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/html-minifier-terser/node_modules/commander": {
+            "version": "4.1.1",
+            "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+            "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
             "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "node": ">= 6"
             }
         },
-        "node_modules/jest-matcher-utils/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/html-minifier-terser/node_modules/terser": {
+            "version": "4.8.1",
+            "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
+            "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "commander": "^2.20.0",
+                "source-map": "~0.6.1",
+                "source-map-support": "~0.5.12"
+            },
+            "bin": {
+                "terser": "bin/terser"
             },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">=6.0.0"
             }
         },
-        "node_modules/jest-matcher-utils/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+        "node_modules/html-minifier-terser/node_modules/terser/node_modules/commander": {
+            "version": "2.20.3",
+            "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+            "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
             "dev": true
         },
-        "node_modules/jest-matcher-utils/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/html-parse-stringify": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+            "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+            "dependencies": {
+                "void-elements": "3.1.0"
+            }
+        },
+        "node_modules/htmlparser2": {
+            "version": "4.1.0",
+            "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz",
+            "integrity": "sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==",
             "dev": true,
-            "engines": {
-                "node": ">=8"
+            "dependencies": {
+                "domelementtype": "^2.0.1",
+                "domhandler": "^3.0.0",
+                "domutils": "^2.0.0",
+                "entities": "^2.0.0"
             }
         },
-        "node_modules/jest-matcher-utils/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+        "node_modules/http-deceiver": {
+            "version": "1.2.7",
+            "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
+            "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==",
+            "dev": true
+        },
+        "node_modules/http-errors": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+            "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
             "dev": true,
             "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
+                "depd": "2.0.0",
+                "inherits": "2.0.4",
+                "setprototypeof": "1.2.0",
+                "statuses": "2.0.1",
+                "toidentifier": "1.0.1"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/jest-matcher-utils/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "node": ">= 0.8"
             }
         },
-        "node_modules/jest-matcher-utils/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+        "node_modules/http-parser-js": {
+            "version": "0.5.8",
+            "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz",
+            "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==",
             "dev": true
         },
-        "node_modules/jest-matcher-utils/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/http-proxy": {
+            "version": "1.18.1",
+            "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
+            "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
+                "eventemitter3": "^4.0.0",
+                "follow-redirects": "^1.0.0",
+                "requires-port": "^1.0.0"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">=8.0.0"
             }
         },
-        "node_modules/jest-message-util": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
-            "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
+        "node_modules/http-proxy-agent": {
+            "version": "7.0.2",
+            "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+            "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "@babel/code-frame": "^7.12.13",
-                "@jest/types": "^29.6.3",
-                "@types/stack-utils": "^2.0.0",
-                "chalk": "^4.0.0",
-                "graceful-fs": "^4.2.9",
-                "micromatch": "^4.0.4",
-                "pretty-format": "^29.7.0",
-                "slash": "^3.0.0",
-                "stack-utils": "^2.0.3"
+                "agent-base": "^7.1.0",
+                "debug": "^4.3.4"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">= 14"
             }
         },
-        "node_modules/jest-message-util/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/http-proxy-middleware": {
+            "version": "2.0.6",
+            "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz",
+            "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "@types/http-proxy": "^1.17.8",
+                "http-proxy": "^1.18.1",
+                "is-glob": "^4.0.1",
+                "is-plain-obj": "^3.0.0",
+                "micromatch": "^4.0.2"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">=12.0.0"
             },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            "peerDependencies": {
+                "@types/express": "^4.17.13"
+            },
+            "peerDependenciesMeta": {
+                "@types/express": {
+                    "optional": true
+                }
             }
         },
-        "node_modules/jest-message-util/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/https-browserify": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
+            "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==",
+            "dev": true
+        },
+        "node_modules/https-proxy-agent": {
+            "version": "7.0.6",
+            "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+            "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "agent-base": "^7.1.2",
+                "debug": "4"
             },
             "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "node": ">= 14"
             }
         },
-        "node_modules/jest-message-util/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/human-signals": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+            "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
             "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">=10.17.0"
             }
         },
-        "node_modules/jest-message-util/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
+        "node_modules/i18next": {
+            "version": "23.14.0",
+            "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.14.0.tgz",
+            "integrity": "sha512-Y5GL4OdA8IU2geRrt2+Uc1iIhsjICdHZzT9tNwQ3TVqdNzgxHToGCKf/TPRP80vTCAP6svg2WbbJL+Gx5MFQVA==",
+            "funding": [
+                {
+                    "type": "individual",
+                    "url": "https://locize.com"
+                },
+                {
+                    "type": "individual",
+                    "url": "https://locize.com/i18next.html"
+                },
+                {
+                    "type": "individual",
+                    "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+                }
+            ],
+            "dependencies": {
+                "@babel/runtime": "^7.23.2"
+            }
         },
-        "node_modules/jest-message-util/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
+        "node_modules/i18next-browser-languagedetector": {
+            "version": "8.0.0",
+            "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz",
+            "integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==",
+            "dependencies": {
+                "@babel/runtime": "^7.23.2"
             }
         },
-        "node_modules/jest-message-util/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+        "node_modules/iconv-lite": {
+            "version": "0.4.24",
+            "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+            "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
             "dev": true,
             "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
+                "safer-buffer": ">= 2.1.2 < 3"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=0.10.0"
             }
         },
-        "node_modules/jest-message-util/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+        "node_modules/icss-utils": {
+            "version": "5.1.0",
+            "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+            "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
             "dev": true,
             "engines": {
-                "node": ">=10"
+                "node": "^10 || ^12 || >= 14"
             },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            "peerDependencies": {
+                "postcss": "^8.1.0"
             }
         },
-        "node_modules/jest-message-util/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-            "dev": true
+        "node_modules/ieee754": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+            "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/feross"
+                },
+                {
+                    "type": "patreon",
+                    "url": "https://www.patreon.com/feross"
+                },
+                {
+                    "type": "consulting",
+                    "url": "https://feross.org/support"
+                }
+            ]
         },
-        "node_modules/jest-message-util/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/ignore": {
+            "version": "5.3.2",
+            "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+            "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+            "dev": true,
+            "engines": {
+                "node": ">= 4"
+            }
+        },
+        "node_modules/imagemin": {
+            "version": "7.0.1",
+            "resolved": "https://registry.npmjs.org/imagemin/-/imagemin-7.0.1.tgz",
+            "integrity": "sha512-33AmZ+xjZhg2JMCe+vDf6a9mzWukE7l+wAtesjE7KyteqqKjzxv7aVQeWnul1Ve26mWvEQqyPwl0OctNBfSR9w==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
+                "file-type": "^12.0.0",
+                "globby": "^10.0.0",
+                "graceful-fs": "^4.2.2",
+                "junk": "^3.1.0",
+                "make-dir": "^3.0.0",
+                "p-pipe": "^3.0.0",
+                "replace-ext": "^1.0.0"
             },
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/jest-mock": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz",
-            "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==",
+        "node_modules/imagemin/node_modules/make-dir": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+            "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
             "dev": true,
             "dependencies": {
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "jest-util": "^29.7.0"
+                "semver": "^6.0.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-pnp-resolver": {
-            "version": "1.2.3",
-            "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz",
-            "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==",
+        "node_modules/img-loader": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/img-loader/-/img-loader-4.0.0.tgz",
+            "integrity": "sha512-UwRcPQdwdOyEHyCxe1V9s9YFwInwEWCpoO+kJGfIqDrBDqA8jZUsEZTxQ0JteNPGw/Gupmwesk2OhLTcnw6tnQ==",
             "dev": true,
+            "dependencies": {
+                "loader-utils": "^1.1.0"
+            },
             "engines": {
-                "node": ">=6"
+                "node": ">=12"
             },
             "peerDependencies": {
-                "jest-resolve": "*"
-            },
-            "peerDependenciesMeta": {
-                "jest-resolve": {
-                    "optional": true
-                }
+                "imagemin": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
             }
         },
-        "node_modules/jest-regex-util": {
-            "version": "29.6.3",
-            "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz",
-            "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==",
+        "node_modules/img-loader/node_modules/json5": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+            "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
             "dev": true,
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+            "dependencies": {
+                "minimist": "^1.2.0"
+            },
+            "bin": {
+                "json5": "lib/cli.js"
             }
         },
-        "node_modules/jest-resolve": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz",
-            "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==",
+        "node_modules/img-loader/node_modules/loader-utils": {
+            "version": "1.4.2",
+            "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
+            "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
             "dev": true,
             "dependencies": {
-                "chalk": "^4.0.0",
-                "graceful-fs": "^4.2.9",
-                "jest-haste-map": "^29.7.0",
-                "jest-pnp-resolver": "^1.2.2",
-                "jest-util": "^29.7.0",
-                "jest-validate": "^29.7.0",
-                "resolve": "^1.20.0",
-                "resolve.exports": "^2.0.0",
-                "slash": "^3.0.0"
+                "big.js": "^5.2.2",
+                "emojis-list": "^3.0.0",
+                "json5": "^1.0.1"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=4.0.0"
             }
         },
-        "node_modules/jest-resolve-dependencies": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz",
-            "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==",
+        "node_modules/immediate": {
+            "version": "3.0.6",
+            "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+            "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+        },
+        "node_modules/immutable": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
+            "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
+            "dev": true
+        },
+        "node_modules/import-fresh": {
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+            "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
             "dev": true,
             "dependencies": {
-                "jest-regex-util": "^29.6.3",
-                "jest-snapshot": "^29.7.0"
+                "parent-module": "^1.0.0",
+                "resolve-from": "^4.0.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=6"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-resolve/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/import-fresh/node_modules/resolve-from": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+            "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
             "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
             "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "node": ">=4"
             }
         },
-        "node_modules/jest-resolve/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/import-local": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
+            "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
             "dev": true,
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "pkg-dir": "^4.2.0",
+                "resolve-cwd": "^3.0.0"
+            },
+            "bin": {
+                "import-local-fixture": "fixtures/cli.js"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">=8"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-resolve/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/imurmurhash": {
+            "version": "0.1.4",
+            "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+            "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
             "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">=0.8.19"
             }
         },
-        "node_modules/jest-resolve/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/jest-resolve/node_modules/has-flag": {
+        "node_modules/indent-string": {
             "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+            "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+            "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
             "dev": true,
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/jest-resolve/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/inflight": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+            "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+            "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=8"
+                "once": "^1.3.0",
+                "wrappy": "1"
             }
         },
-        "node_modules/jest-runner": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz",
-            "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==",
-            "dev": true,
-            "dependencies": {
-                "@jest/console": "^29.7.0",
-                "@jest/environment": "^29.7.0",
-                "@jest/test-result": "^29.7.0",
-                "@jest/transform": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "chalk": "^4.0.0",
-                "emittery": "^0.13.1",
-                "graceful-fs": "^4.2.9",
-                "jest-docblock": "^29.7.0",
-                "jest-environment-node": "^29.7.0",
-                "jest-haste-map": "^29.7.0",
-                "jest-leak-detector": "^29.7.0",
-                "jest-message-util": "^29.7.0",
-                "jest-resolve": "^29.7.0",
-                "jest-runtime": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "jest-watcher": "^29.7.0",
-                "jest-worker": "^29.7.0",
-                "p-limit": "^3.1.0",
-                "source-map-support": "0.5.13"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/jest-runner/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/inherits": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+            "dev": true
+        },
+        "node_modules/internal-slot": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz",
+            "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "es-errors": "^1.3.0",
+                "hasown": "^2.0.0",
+                "side-channel": "^1.0.4"
             },
             "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "node": ">= 0.4"
             }
         },
-        "node_modules/jest-runner/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-            "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
+        "node_modules/internmap": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+            "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
             "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "node": ">=12"
             }
         },
-        "node_modules/jest-runner/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/interpret": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
+            "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
             "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">= 0.10"
             }
         },
-        "node_modules/jest-runner/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/jest-runner/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
+        "node_modules/invariant": {
+            "version": "2.2.4",
+            "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+            "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+            "dependencies": {
+                "loose-envify": "^1.0.0"
             }
         },
-        "node_modules/jest-runner/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/ipaddr.js": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
+            "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
             "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
             "engines": {
-                "node": ">=8"
+                "node": ">= 10"
             }
         },
-        "node_modules/jest-runtime": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz",
-            "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==",
+        "node_modules/is-arguments": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
+            "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
             "dev": true,
             "dependencies": {
-                "@jest/environment": "^29.7.0",
-                "@jest/fake-timers": "^29.7.0",
-                "@jest/globals": "^29.7.0",
-                "@jest/source-map": "^29.6.3",
-                "@jest/test-result": "^29.7.0",
-                "@jest/transform": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "chalk": "^4.0.0",
-                "cjs-module-lexer": "^1.0.0",
-                "collect-v8-coverage": "^1.0.0",
-                "glob": "^7.1.3",
-                "graceful-fs": "^4.2.9",
-                "jest-haste-map": "^29.7.0",
-                "jest-message-util": "^29.7.0",
-                "jest-mock": "^29.7.0",
-                "jest-regex-util": "^29.6.3",
-                "jest-resolve": "^29.7.0",
-                "jest-snapshot": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "slash": "^3.0.0",
-                "strip-bom": "^4.0.0"
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-runtime/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/is-array-buffer": {
+            "version": "3.0.4",
+            "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
+            "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "call-bind": "^1.0.2",
+                "get-intrinsic": "^1.2.1"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-runtime/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/is-arrayish": {
+            "version": "0.2.1",
+            "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+            "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+            "dev": true
+        },
+        "node_modules/is-async-function": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz",
+            "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==",
             "dev": true,
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "has-tostringtag": "^1.0.0"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-runtime/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/is-bigint": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+            "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "has-bigints": "^1.0.1"
             },
-            "engines": {
-                "node": ">=7.0.0"
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-runtime/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/jest-runtime/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/is-binary-path": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+            "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
             "dev": true,
+            "dependencies": {
+                "binary-extensions": "^2.0.0"
+            },
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/jest-runtime/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/is-boolean-object": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+            "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-snapshot": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
-            "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
+        "node_modules/is-buffer": {
+            "version": "1.1.6",
+            "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+            "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
+            "dev": true
+        },
+        "node_modules/is-callable": {
+            "version": "1.2.7",
+            "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+            "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
             "dev": true,
-            "dependencies": {
-                "@babel/core": "^7.11.6",
-                "@babel/generator": "^7.7.2",
-                "@babel/plugin-syntax-jsx": "^7.7.2",
-                "@babel/plugin-syntax-typescript": "^7.7.2",
-                "@babel/types": "^7.3.3",
-                "@jest/expect-utils": "^29.7.0",
-                "@jest/transform": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "babel-preset-current-node-syntax": "^1.0.0",
-                "chalk": "^4.0.0",
-                "expect": "^29.7.0",
-                "graceful-fs": "^4.2.9",
-                "jest-diff": "^29.7.0",
-                "jest-get-type": "^29.6.3",
-                "jest-matcher-utils": "^29.7.0",
-                "jest-message-util": "^29.7.0",
-                "jest-util": "^29.7.0",
-                "natural-compare": "^1.4.0",
-                "pretty-format": "^29.7.0",
-                "semver": "^7.5.3"
-            },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-snapshot/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/is-core-module": {
+            "version": "2.15.1",
+            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
+            "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "hasown": "^2.0.2"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-snapshot/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/is-data-view": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz",
+            "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==",
             "dev": true,
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "is-typed-array": "^1.1.13"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-snapshot/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/is-date-object": {
+            "version": "1.0.5",
+            "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+            "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "has-tostringtag": "^1.0.0"
             },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-snapshot/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/jest-snapshot/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/is-docker": {
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+            "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
             "dev": true,
+            "bin": {
+                "is-docker": "cli.js"
+            },
             "engines": {
                 "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-snapshot/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+        "node_modules/is-extglob": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+            "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
             "dev": true,
-            "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
-            },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=0.10.0"
             }
         },
-        "node_modules/jest-snapshot/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+        "node_modules/is-finalizationregistry": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz",
+            "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==",
             "dev": true,
-            "engines": {
-                "node": ">=10"
+            "dependencies": {
+                "call-bind": "^1.0.2"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-snapshot/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-            "dev": true
-        },
-        "node_modules/jest-snapshot/node_modules/semver": {
-            "version": "7.6.3",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
-            "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+        "node_modules/is-fullwidth-code-point": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
             "dev": true,
-            "bin": {
-                "semver": "bin/semver.js"
-            },
             "engines": {
-                "node": ">=10"
+                "node": ">=8"
             }
         },
-        "node_modules/jest-snapshot/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/is-generator-function": {
+            "version": "1.0.10",
+            "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
+            "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
+                "has-tostringtag": "^1.0.0"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-util": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
-            "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
+        "node_modules/is-glob": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+            "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
             "dev": true,
             "dependencies": {
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "chalk": "^4.0.0",
-                "ci-info": "^3.2.0",
-                "graceful-fs": "^4.2.9",
-                "picomatch": "^2.2.3"
+                "is-extglob": "^2.1.1"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=0.10.0"
             }
         },
-        "node_modules/jest-util/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/is-map": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+            "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
             "dev": true,
-            "dependencies": {
-                "color-convert": "^2.0.1"
-            },
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-util/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/is-negative-zero": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+            "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
             "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-util/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/is-number": {
+            "version": "7.0.0",
+            "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+            "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
             "dev": true,
-            "dependencies": {
-                "color-name": "~1.1.4"
-            },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">=0.12.0"
             }
         },
-        "node_modules/jest-util/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/jest-util/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/is-number-object": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz",
+            "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==",
             "dev": true,
+            "dependencies": {
+                "has-tostringtag": "^1.0.0"
+            },
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-util/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/is-path-inside": {
+            "version": "3.0.3",
+            "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+            "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
             "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
             "engines": {
                 "node": ">=8"
             }
         },
-        "node_modules/jest-validate": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
-            "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==",
+        "node_modules/is-plain-obj": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
+            "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
             "dev": true,
-            "dependencies": {
-                "@jest/types": "^29.6.3",
-                "camelcase": "^6.2.0",
-                "chalk": "^4.0.0",
-                "jest-get-type": "^29.6.3",
-                "leven": "^3.1.0",
-                "pretty-format": "^29.7.0"
-            },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-validate/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/is-plain-object": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+            "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
+                "isobject": "^3.0.1"
             },
             "engines": {
-                "node": ">=8"
-            },
-            "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "node": ">=0.10.0"
             }
         },
-        "node_modules/jest-validate/node_modules/camelcase": {
-            "version": "6.3.0",
-            "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
-            "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+        "node_modules/is-potential-custom-element-name": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+            "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/is-regex": {
+            "version": "1.1.4",
+            "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+            "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
             "dev": true,
+            "dependencies": {
+                "call-bind": "^1.0.2",
+                "has-tostringtag": "^1.0.0"
+            },
             "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-validate/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/is-set": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+            "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
             "dev": true,
-            "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
-            },
             "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-validate/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/is-shared-array-buffer": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz",
+            "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "call-bind": "^1.0.7"
             },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-validate/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-            "dev": true
-        },
-        "node_modules/jest-validate/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+        "node_modules/is-stream": {
+            "version": "2.0.1",
+            "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+            "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
             "dev": true,
             "engines": {
                 "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/jest-validate/node_modules/pretty-format": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-            "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+        "node_modules/is-string": {
+            "version": "1.0.7",
+            "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+            "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
             "dev": true,
             "dependencies": {
-                "@jest/schemas": "^29.6.3",
-                "ansi-styles": "^5.0.0",
-                "react-is": "^18.0.0"
+                "has-tostringtag": "^1.0.0"
             },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-validate/node_modules/pretty-format/node_modules/ansi-styles": {
-            "version": "5.2.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+        "node_modules/is-symbol": {
+            "version": "1.0.4",
+            "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+            "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
             "dev": true,
+            "dependencies": {
+                "has-symbols": "^1.0.2"
+            },
             "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-validate/node_modules/react-is": {
-            "version": "18.3.1",
-            "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-            "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-            "dev": true
-        },
-        "node_modules/jest-validate/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/is-typed-array": {
+            "version": "1.1.13",
+            "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz",
+            "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
+                "which-typed-array": "^1.1.14"
             },
             "engines": {
-                "node": ">=8"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-watcher": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz",
-            "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==",
+        "node_modules/is-weakmap": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+            "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
             "dev": true,
-            "dependencies": {
-                "@jest/test-result": "^29.7.0",
-                "@jest/types": "^29.6.3",
-                "@types/node": "*",
-                "ansi-escapes": "^4.2.1",
-                "chalk": "^4.0.0",
-                "emittery": "^0.13.1",
-                "jest-util": "^29.7.0",
-                "string-length": "^4.0.1"
-            },
             "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+                "node": ">= 0.4"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-watcher/node_modules/ansi-styles": {
-            "version": "4.3.0",
-            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+        "node_modules/is-weakref": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+            "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
             "dev": true,
             "dependencies": {
-                "color-convert": "^2.0.1"
-            },
-            "engines": {
-                "node": ">=8"
+                "call-bind": "^1.0.2"
             },
             "funding": {
-                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-watcher/node_modules/chalk": {
-            "version": "4.1.2",
-            "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-            "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+        "node_modules/is-weakset": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz",
+            "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==",
             "dev": true,
             "dependencies": {
-                "ansi-styles": "^4.1.0",
-                "supports-color": "^7.1.0"
+                "call-bind": "^1.0.7",
+                "get-intrinsic": "^1.2.4"
             },
             "engines": {
-                "node": ">=10"
+                "node": ">= 0.4"
             },
             "funding": {
-                "url": "https://github.com/chalk/chalk?sponsor=1"
+                "url": "https://github.com/sponsors/ljharb"
             }
         },
-        "node_modules/jest-watcher/node_modules/color-convert": {
-            "version": "2.0.1",
-            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
-            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+        "node_modules/is-wsl": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+            "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
             "dev": true,
             "dependencies": {
-                "color-name": "~1.1.4"
+                "is-docker": "^2.0.0"
             },
             "engines": {
-                "node": ">=7.0.0"
+                "node": ">=8"
             }
         },
-        "node_modules/jest-watcher/node_modules/color-name": {
-            "version": "1.1.4",
-            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
-            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+        "node_modules/isarray": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+            "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
             "dev": true
         },
-        "node_modules/jest-watcher/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
+        "node_modules/isexe": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+            "dev": true
         },
-        "node_modules/jest-watcher/node_modules/supports-color": {
-            "version": "7.2.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+        "node_modules/isobject": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+            "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
             "dev": true,
-            "dependencies": {
-                "has-flag": "^4.0.0"
-            },
             "engines": {
-                "node": ">=8"
+                "node": ">=0.10.0"
             }
         },
-        "node_modules/jest-worker": {
-            "version": "29.7.0",
-            "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz",
-            "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==",
+        "node_modules/iterator.prototype": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz",
+            "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==",
             "dev": true,
             "dependencies": {
-                "@types/node": "*",
-                "jest-util": "^29.7.0",
-                "merge-stream": "^2.0.0",
-                "supports-color": "^8.0.0"
-            },
-            "engines": {
-                "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-            }
-        },
-        "node_modules/jest-worker/node_modules/has-flag": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
+                "define-properties": "^1.2.1",
+                "get-intrinsic": "^1.2.1",
+                "has-symbols": "^1.0.3",
+                "reflect.getprototypeof": "^1.0.4",
+                "set-function-name": "^2.0.1"
             }
         },
-        "node_modules/jest-worker/node_modules/supports-color": {
-            "version": "8.1.1",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
-            "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+        "node_modules/jackspeak": {
+            "version": "3.4.3",
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+            "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
             "dev": true,
             "dependencies": {
-                "has-flag": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
+                "@isaacs/cliui": "^8.0.2"
             },
             "funding": {
-                "url": "https://github.com/chalk/supports-color?sponsor=1"
+                "url": "https://github.com/sponsors/isaacs"
+            },
+            "optionalDependencies": {
+                "@pkgjs/parseargs": "^0.11.0"
             }
         },
         "node_modules/jiti": {
             "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
             "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
         },
-        "node_modules/js-yaml": {
-            "version": "3.14.1",
-            "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
-            "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
-            "dev": true,
-            "dependencies": {
-                "argparse": "^1.0.7",
-                "esprima": "^4.0.0"
-            },
-            "bin": {
-                "js-yaml": "bin/js-yaml.js"
-            }
-        },
         "node_modules/jsdom": {
-            "version": "20.0.3",
-            "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz",
-            "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==",
-            "dev": true,
-            "dependencies": {
-                "abab": "^2.0.6",
-                "acorn": "^8.8.1",
-                "acorn-globals": "^7.0.0",
-                "cssom": "^0.5.0",
-                "cssstyle": "^2.3.0",
-                "data-urls": "^3.0.2",
-                "decimal.js": "^10.4.2",
-                "domexception": "^4.0.0",
-                "escodegen": "^2.0.0",
-                "form-data": "^4.0.0",
-                "html-encoding-sniffer": "^3.0.0",
-                "http-proxy-agent": "^5.0.0",
-                "https-proxy-agent": "^5.0.1",
+            "version": "26.1.0",
+            "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
+            "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "cssstyle": "^4.2.1",
+                "data-urls": "^5.0.0",
+                "decimal.js": "^10.5.0",
+                "html-encoding-sniffer": "^4.0.0",
+                "http-proxy-agent": "^7.0.2",
+                "https-proxy-agent": "^7.0.6",
                 "is-potential-custom-element-name": "^1.0.1",
-                "nwsapi": "^2.2.2",
-                "parse5": "^7.1.1",
+                "nwsapi": "^2.2.16",
+                "parse5": "^7.2.1",
+                "rrweb-cssom": "^0.8.0",
                 "saxes": "^6.0.0",
                 "symbol-tree": "^3.2.4",
-                "tough-cookie": "^4.1.2",
-                "w3c-xmlserializer": "^4.0.0",
+                "tough-cookie": "^5.1.1",
+                "w3c-xmlserializer": "^5.0.0",
                 "webidl-conversions": "^7.0.0",
-                "whatwg-encoding": "^2.0.0",
-                "whatwg-mimetype": "^3.0.0",
-                "whatwg-url": "^11.0.0",
-                "ws": "^8.11.0",
-                "xml-name-validator": "^4.0.0"
+                "whatwg-encoding": "^3.1.1",
+                "whatwg-mimetype": "^4.0.0",
+                "whatwg-url": "^14.1.1",
+                "ws": "^8.18.0",
+                "xml-name-validator": "^5.0.0"
             },
             "engines": {
-                "node": ">=14"
+                "node": ">=18"
             },
             "peerDependencies": {
-                "canvas": "^2.5.0"
+                "canvas": "^3.0.0"
             },
             "peerDependenciesMeta": {
                 "canvas": {
             }
         },
         "node_modules/jsesc": {
-            "version": "2.5.2",
-            "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
-            "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+            "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
             "dev": true,
+            "license": "MIT",
             "bin": {
                 "jsesc": "bin/jsesc"
             },
             "engines": {
-                "node": ">=4"
+                "node": ">=6"
             }
         },
         "node_modules/json-buffer": {
                 "node": ">=0.10.0"
             }
         },
-        "node_modules/kleur": {
-            "version": "3.0.3",
-            "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
-            "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
-            "dev": true,
-            "engines": {
-                "node": ">=6"
-            }
-        },
         "node_modules/klona": {
             "version": "2.0.6",
             "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz",
                 "node": ">=8"
             }
         },
+        "node_modules/laravel-vite-plugin": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.3.0.tgz",
+            "integrity": "sha512-P5qyG56YbYxM8OuYmK2OkhcKe0AksNVJUjq9LUZ5tOekU9fBn9LujYyctI4t9XoLjuMvHJXXpCoPntY1oKltuA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "picocolors": "^1.0.0",
+                "vite-plugin-full-reload": "^1.1.0"
+            },
+            "bin": {
+                "clean-orphaned-assets": "bin/clean.js"
+            },
+            "engines": {
+                "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+            },
+            "peerDependencies": {
+                "vite": "^5.0.0 || ^6.0.0"
+            }
+        },
         "node_modules/launch-editor": {
             "version": "2.8.1",
             "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.8.1.tgz",
                 "shell-quote": "^1.8.1"
             }
         },
-        "node_modules/leven": {
-            "version": "3.1.0",
-            "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
-            "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
-            "dev": true,
-            "engines": {
-                "node": ">=6"
-            }
-        },
         "node_modules/levn": {
             "version": "0.4.1",
             "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
                 "loose-envify": "cli.js"
             }
         },
+        "node_modules/loupe": {
+            "version": "3.1.4",
+            "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz",
+            "integrity": "sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/lower-case": {
             "version": "2.0.2",
             "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
             "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
             "dev": true,
             "dependencies": {
-                "yallist": "^3.0.2"
-            }
-        },
-        "node_modules/lz-string": {
-            "version": "1.5.0",
-            "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
-            "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
-            "dev": true,
-            "bin": {
-                "lz-string": "bin/bin.js"
-            }
-        },
-        "node_modules/make-dir": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
-            "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
-            "dev": true,
-            "dependencies": {
-                "semver": "^7.5.3"
-            },
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
+                "yallist": "^3.0.2"
             }
         },
-        "node_modules/make-dir/node_modules/semver": {
-            "version": "7.6.3",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
-            "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+        "node_modules/lz-string": {
+            "version": "1.5.0",
+            "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+            "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
             "dev": true,
             "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
+                "lz-string": "bin/bin.js"
             }
         },
-        "node_modules/makeerror": {
-            "version": "1.0.12",
-            "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
-            "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+        "node_modules/magic-string": {
+            "version": "0.30.17",
+            "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
+            "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "tmpl": "1.0.5"
+                "@jridgewell/sourcemap-codec": "^1.5.0"
             }
         },
         "node_modules/md5": {
             }
         },
         "node_modules/ms": {
-            "version": "2.1.2",
-            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
-            "dev": true
+            "version": "2.1.3",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/multicast-dns": {
             "version": "7.2.5",
             }
         },
         "node_modules/nanoid": {
-            "version": "3.3.7",
-            "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
-            "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+            "version": "3.3.11",
+            "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+            "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
             "dev": true,
             "funding": [
                 {
                     "url": "https://github.com/sponsors/ai"
                 }
             ],
+            "license": "MIT",
             "bin": {
                 "nanoid": "bin/nanoid.cjs"
             },
                 "node": ">= 6.13.0"
             }
         },
-        "node_modules/node-int64": {
-            "version": "0.4.0",
-            "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
-            "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
-            "dev": true
-        },
         "node_modules/node-libs-browser": {
             "version": "2.2.1",
             "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz",
             }
         },
         "node_modules/node-releases": {
-            "version": "2.0.18",
-            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
-            "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
-            "dev": true
+            "version": "2.0.19",
+            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
+            "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/normalize-path": {
             "version": "3.0.0",
             }
         },
         "node_modules/nwsapi": {
-            "version": "2.2.12",
-            "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz",
-            "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==",
-            "dev": true
+            "version": "2.2.20",
+            "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz",
+            "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/object-assign": {
             "version": "4.1.1",
             }
         },
         "node_modules/parse5": {
-            "version": "7.1.2",
-            "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
-            "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
+            "version": "7.3.0",
+            "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+            "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "entities": "^4.4.0"
+                "entities": "^6.0.0"
             },
             "funding": {
                 "url": "https://github.com/inikulin/parse5?sponsor=1"
             }
         },
         "node_modules/parse5/node_modules/entities": {
-            "version": "4.5.0",
-            "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
-            "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+            "version": "6.0.1",
+            "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+            "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
             "dev": true,
+            "license": "BSD-2-Clause",
             "engines": {
                 "node": ">=0.12"
             },
                 "node": ">=8"
             }
         },
+        "node_modules/pathe": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+            "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/pathval": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
+            "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">= 14.16"
+            }
+        },
         "node_modules/pbkdf2": {
             "version": "3.1.2",
             "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
             }
         },
         "node_modules/picocolors": {
-            "version": "1.0.1",
-            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
-            "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
-            "dev": true
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+            "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+            "dev": true,
+            "license": "ISC"
         },
         "node_modules/picomatch": {
             "version": "2.3.1",
             }
         },
         "node_modules/postcss": {
-            "version": "8.4.44",
-            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.44.tgz",
-            "integrity": "sha512-Aweb9unOEpQ3ezu4Q00DPvvM2ZTUitJdNKeP/+uQgr1IBIqu574IaZoURId7BKtWMREwzKa9OgzPzezWGPWFQw==",
+            "version": "8.5.6",
+            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+            "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
             "dev": true,
             "funding": [
                 {
                     "url": "https://github.com/sponsors/ai"
                 }
             ],
+            "license": "MIT",
             "dependencies": {
-                "nanoid": "^3.3.7",
-                "picocolors": "^1.0.1",
-                "source-map-js": "^1.2.0"
+                "nanoid": "^3.3.11",
+                "picocolors": "^1.1.1",
+                "source-map-js": "^1.2.1"
             },
             "engines": {
                 "node": "^10 || ^12 || >=14"
                 }
             }
         },
+        "node_modules/postcss-load-config/node_modules/yaml": {
+            "version": "1.10.2",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+            "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+            "dev": true,
+            "license": "ISC",
+            "engines": {
+                "node": ">= 6"
+            }
+        },
         "node_modules/postcss-loader": {
             "version": "6.2.1",
             "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz",
             "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
             "dev": true
         },
-        "node_modules/prompts": {
-            "version": "2.4.2",
-            "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
-            "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
-            "dev": true,
-            "dependencies": {
-                "kleur": "^3.0.3",
-                "sisteransi": "^1.0.5"
-            },
-            "engines": {
-                "node": ">= 6"
-            }
-        },
         "node_modules/prop-types": {
             "version": "15.8.1",
             "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
             "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
             "dev": true
         },
-        "node_modules/psl": {
-            "version": "1.9.0",
-            "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
-            "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
-            "dev": true
-        },
         "node_modules/public-encrypt": {
             "version": "4.0.3",
             "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
             "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
             "dev": true
         },
-        "node_modules/pure-rand": {
-            "version": "6.1.0",
-            "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
-            "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
-            "dev": true,
-            "funding": [
-                {
-                    "type": "individual",
-                    "url": "https://github.com/sponsors/dubzzz"
-                },
-                {
-                    "type": "opencollective",
-                    "url": "https://opencollective.com/fast-check"
-                }
-            ]
-        },
         "node_modules/pusher-js": {
             "version": "8.3.0",
             "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.3.0.tgz",
                 "node": ">=0.4.x"
             }
         },
-        "node_modules/querystringify": {
-            "version": "2.2.0",
-            "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
-            "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
-            "dev": true
-        },
         "node_modules/queue-microtask": {
             "version": "1.2.3",
             "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
             "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
             "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
         },
+        "node_modules/react-refresh": {
+            "version": "0.17.0",
+            "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+            "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
         "node_modules/react-router": {
             "version": "6.26.1",
             "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz",
             "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
             "dev": true
         },
-        "node_modules/resolve.exports": {
-            "version": "2.0.2",
-            "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz",
-            "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/retry": {
             "version": "0.13.1",
             "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
                 "inherits": "^2.0.1"
             }
         },
+        "node_modules/rollup": {
+            "version": "4.44.0",
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz",
+            "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/estree": "1.0.8"
+            },
+            "bin": {
+                "rollup": "dist/bin/rollup"
+            },
+            "engines": {
+                "node": ">=18.0.0",
+                "npm": ">=8.0.0"
+            },
+            "optionalDependencies": {
+                "@rollup/rollup-android-arm-eabi": "4.44.0",
+                "@rollup/rollup-android-arm64": "4.44.0",
+                "@rollup/rollup-darwin-arm64": "4.44.0",
+                "@rollup/rollup-darwin-x64": "4.44.0",
+                "@rollup/rollup-freebsd-arm64": "4.44.0",
+                "@rollup/rollup-freebsd-x64": "4.44.0",
+                "@rollup/rollup-linux-arm-gnueabihf": "4.44.0",
+                "@rollup/rollup-linux-arm-musleabihf": "4.44.0",
+                "@rollup/rollup-linux-arm64-gnu": "4.44.0",
+                "@rollup/rollup-linux-arm64-musl": "4.44.0",
+                "@rollup/rollup-linux-loongarch64-gnu": "4.44.0",
+                "@rollup/rollup-linux-powerpc64le-gnu": "4.44.0",
+                "@rollup/rollup-linux-riscv64-gnu": "4.44.0",
+                "@rollup/rollup-linux-riscv64-musl": "4.44.0",
+                "@rollup/rollup-linux-s390x-gnu": "4.44.0",
+                "@rollup/rollup-linux-x64-gnu": "4.44.0",
+                "@rollup/rollup-linux-x64-musl": "4.44.0",
+                "@rollup/rollup-win32-arm64-msvc": "4.44.0",
+                "@rollup/rollup-win32-ia32-msvc": "4.44.0",
+                "@rollup/rollup-win32-x64-msvc": "4.44.0",
+                "fsevents": "~2.3.2"
+            }
+        },
+        "node_modules/rrweb-cssom": {
+            "version": "0.8.0",
+            "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+            "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/run-parallel": {
             "version": "1.2.0",
             "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
             "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
             "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
             "dev": true,
+            "license": "ISC",
             "dependencies": {
                 "xmlchars": "^2.2.0"
             },
             "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
             "dev": true
         },
-        "node_modules/send/node_modules/ms": {
-            "version": "2.1.3",
-            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-            "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-            "dev": true
-        },
         "node_modules/serialize-javascript": {
             "version": "6.0.2",
             "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/siginfo": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+            "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+            "dev": true,
+            "license": "ISC"
+        },
         "node_modules/signal-exit": {
             "version": "3.0.7",
             "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
             "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
             "dev": true
         },
-        "node_modules/sisteransi": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
-            "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
-            "dev": true
-        },
         "node_modules/slash": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
             }
         },
         "node_modules/source-map-js": {
-            "version": "1.2.0",
-            "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
-            "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+            "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
             "dev": true,
+            "license": "BSD-3-Clause",
             "engines": {
                 "node": ">=0.10.0"
             }
                 "node": ">= 6"
             }
         },
-        "node_modules/sprintf-js": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
-            "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
-            "dev": true
-        },
         "node_modules/stable": {
             "version": "0.1.8",
             "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
             "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility",
             "dev": true
         },
-        "node_modules/stack-utils": {
-            "version": "2.0.6",
-            "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
-            "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
-            "dev": true,
-            "dependencies": {
-                "escape-string-regexp": "^2.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/stack-utils/node_modules/escape-string-regexp": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
-            "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+        "node_modules/stackback": {
+            "version": "0.0.2",
+            "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+            "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
             "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
+            "license": "MIT"
         },
         "node_modules/statuses": {
             "version": "2.0.1",
             }
         },
         "node_modules/std-env": {
-            "version": "3.7.0",
-            "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz",
-            "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==",
-            "dev": true
+            "version": "3.9.0",
+            "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
+            "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/stop-iteration-iterator": {
             "version": "1.0.0",
                 "safe-buffer": "~5.2.0"
             }
         },
-        "node_modules/string-length": {
-            "version": "4.0.2",
-            "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
-            "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
-            "dev": true,
-            "dependencies": {
-                "char-regex": "^1.0.2",
-                "strip-ansi": "^6.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/string-width": {
             "version": "4.2.3",
             "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
                 "node": ">=8"
             }
         },
-        "node_modules/strip-bom": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
-            "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
-            "dev": true,
-            "engines": {
-                "node": ">=8"
-            }
-        },
         "node_modules/strip-final-newline": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
+        "node_modules/strip-literal": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz",
+            "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "js-tokens": "^9.0.1"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/antfu"
+            }
+        },
+        "node_modules/strip-literal/node_modules/js-tokens": {
+            "version": "9.0.1",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+            "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+            "dev": true,
+            "license": "MIT"
+        },
         "node_modules/style-loader": {
             "version": "2.0.0",
             "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz",
                 "url": "https://github.com/sponsors/isaacs"
             }
         },
-        "node_modules/supports-color": {
-            "version": "5.5.0",
-            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-            "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-            "dev": true,
-            "dependencies": {
-                "has-flag": "^3.0.0"
-            },
-            "engines": {
-                "node": ">=4"
-            }
-        },
         "node_modules/supports-preserve-symlinks-flag": {
             "version": "1.0.0",
             "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
             "version": "3.2.4",
             "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
             "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
-            "dev": true
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/tailwindcss": {
             "version": "3.4.10",
                 "url": "https://github.com/sponsors/antonk52"
             }
         },
-        "node_modules/tailwindcss/node_modules/yaml": {
-            "version": "2.5.0",
-            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
-            "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
-            "dev": true,
-            "bin": {
-                "yaml": "bin.mjs"
-            },
-            "engines": {
-                "node": ">= 14"
-            }
-        },
         "node_modules/tapable": {
             "version": "2.2.1",
             "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
                 "source-map": "^0.6.0"
             }
         },
-        "node_modules/test-exclude": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
-            "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
-            "dev": true,
-            "dependencies": {
-                "@istanbuljs/schema": "^0.1.2",
-                "glob": "^7.1.4",
-                "minimatch": "^3.0.4"
-            },
-            "engines": {
-                "node": ">=8"
-            }
-        },
         "node_modules/text-table": {
             "version": "0.2.0",
             "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
                 "thenify": ">= 3.1.0 < 4"
             },
             "engines": {
-                "node": ">=0.8"
+                "node": ">=0.8"
+            }
+        },
+        "node_modules/thunky": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
+            "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
+            "dev": true
+        },
+        "node_modules/timers-browserify": {
+            "version": "2.0.12",
+            "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz",
+            "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==",
+            "dev": true,
+            "dependencies": {
+                "setimmediate": "^1.0.4"
+            },
+            "engines": {
+                "node": ">=0.6.0"
+            }
+        },
+        "node_modules/tiny-case": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
+            "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
+        },
+        "node_modules/tiny-invariant": {
+            "version": "1.3.3",
+            "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+            "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
+        },
+        "node_modules/tiny-warning": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+            "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
+        },
+        "node_modules/tinybench": {
+            "version": "2.9.0",
+            "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+            "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/tinyexec": {
+            "version": "0.3.2",
+            "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+            "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/tinyglobby": {
+            "version": "0.2.14",
+            "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
+            "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "fdir": "^6.4.4",
+                "picomatch": "^4.0.2"
+            },
+            "engines": {
+                "node": ">=12.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/SuperchupuDev"
+            }
+        },
+        "node_modules/tinyglobby/node_modules/fdir": {
+            "version": "6.4.6",
+            "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
+            "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+            "dev": true,
+            "license": "MIT",
+            "peerDependencies": {
+                "picomatch": "^3 || ^4"
+            },
+            "peerDependenciesMeta": {
+                "picomatch": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/tinyglobby/node_modules/picomatch": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+            "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/jonschlinkert"
+            }
+        },
+        "node_modules/tinypool": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+            "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": "^18.0.0 || >=20.0.0"
+            }
+        },
+        "node_modules/tinyrainbow": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+            "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=14.0.0"
             }
         },
-        "node_modules/thunky": {
-            "version": "1.1.0",
-            "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
-            "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
-            "dev": true
+        "node_modules/tinyspy": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz",
+            "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=14.0.0"
+            }
         },
-        "node_modules/timers-browserify": {
-            "version": "2.0.12",
-            "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz",
-            "integrity": "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==",
+        "node_modules/tldts": {
+            "version": "6.1.86",
+            "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+            "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "setimmediate": "^1.0.4"
+                "tldts-core": "^6.1.86"
             },
-            "engines": {
-                "node": ">=0.6.0"
+            "bin": {
+                "tldts": "bin/cli.js"
             }
         },
-        "node_modules/tiny-case": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
-            "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
-        },
-        "node_modules/tiny-invariant": {
-            "version": "1.3.3",
-            "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
-            "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
-        },
-        "node_modules/tiny-warning": {
-            "version": "1.0.3",
-            "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
-            "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
-        },
-        "node_modules/tmpl": {
-            "version": "1.0.5",
-            "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
-            "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
-            "dev": true
+        "node_modules/tldts-core": {
+            "version": "6.1.86",
+            "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+            "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/to-arraybuffer": {
             "version": "1.0.1",
             "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==",
             "dev": true
         },
-        "node_modules/to-fast-properties": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
-            "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
-            "dev": true,
-            "engines": {
-                "node": ">=4"
-            }
-        },
         "node_modules/to-regex-range": {
             "version": "5.0.1",
             "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
             "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
         },
         "node_modules/tough-cookie": {
-            "version": "4.1.4",
-            "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
-            "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+            "version": "5.1.2",
+            "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+            "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
             "dev": true,
+            "license": "BSD-3-Clause",
             "dependencies": {
-                "psl": "^1.1.33",
-                "punycode": "^2.1.1",
-                "universalify": "^0.2.0",
-                "url-parse": "^1.5.3"
+                "tldts": "^6.1.32"
             },
             "engines": {
-                "node": ">=6"
-            }
-        },
-        "node_modules/tough-cookie/node_modules/punycode": {
-            "version": "2.3.1",
-            "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
-            "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
-            "dev": true,
-            "engines": {
-                "node": ">=6"
-            }
-        },
-        "node_modules/tough-cookie/node_modules/universalify": {
-            "version": "0.2.0",
-            "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
-            "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
-            "dev": true,
-            "engines": {
-                "node": ">= 4.0.0"
+                "node": ">=16"
             }
         },
         "node_modules/tr46": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
-            "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+            "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "punycode": "^2.1.1"
+                "punycode": "^2.3.1"
             },
             "engines": {
-                "node": ">=12"
+                "node": ">=18"
             }
         },
         "node_modules/tr46/node_modules/punycode": {
             "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
             "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
             "dev": true,
+            "license": "MIT",
             "engines": {
                 "node": ">=6"
             }
                 "node": ">= 0.8.0"
             }
         },
-        "node_modules/type-detect": {
-            "version": "4.0.8",
-            "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
-            "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
-            "dev": true,
-            "engines": {
-                "node": ">=4"
-            }
-        },
-        "node_modules/type-fest": {
-            "version": "0.21.3",
-            "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
-            "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
-            "dev": true,
-            "engines": {
-                "node": ">=10"
-            },
-            "funding": {
-                "url": "https://github.com/sponsors/sindresorhus"
-            }
-        },
         "node_modules/type-is": {
             "version": "1.6.18",
             "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
             }
         },
         "node_modules/update-browserslist-db": {
-            "version": "1.1.0",
-            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
-            "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
+            "version": "1.1.3",
+            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+            "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
             "dev": true,
             "funding": [
                 {
                     "url": "https://github.com/sponsors/ai"
                 }
             ],
+            "license": "MIT",
             "dependencies": {
-                "escalade": "^3.1.2",
-                "picocolors": "^1.0.1"
+                "escalade": "^3.2.0",
+                "picocolors": "^1.1.1"
             },
             "bin": {
                 "update-browserslist-db": "cli.js"
                 "node": ">= 0.4"
             }
         },
-        "node_modules/url-parse": {
-            "version": "1.5.10",
-            "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
-            "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
-            "dev": true,
-            "dependencies": {
-                "querystringify": "^2.1.1",
-                "requires-port": "^1.0.0"
-            }
-        },
         "node_modules/util": {
             "version": "0.11.1",
             "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
                 "uuid": "dist/bin/uuid"
             }
         },
-        "node_modules/v8-to-istanbul": {
-            "version": "9.3.0",
-            "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
-            "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
-            "dev": true,
-            "dependencies": {
-                "@jridgewell/trace-mapping": "^0.3.12",
-                "@types/istanbul-lib-coverage": "^2.0.1",
-                "convert-source-map": "^2.0.0"
-            },
-            "engines": {
-                "node": ">=10.12.0"
-            }
-        },
         "node_modules/vary": {
             "version": "1.1.2",
             "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
                 "d3-timer": "^3.0.1"
             }
         },
+        "node_modules/vite": {
+            "version": "6.3.5",
+            "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
+            "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "esbuild": "^0.25.0",
+                "fdir": "^6.4.4",
+                "picomatch": "^4.0.2",
+                "postcss": "^8.5.3",
+                "rollup": "^4.34.9",
+                "tinyglobby": "^0.2.13"
+            },
+            "bin": {
+                "vite": "bin/vite.js"
+            },
+            "engines": {
+                "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/vitejs/vite?sponsor=1"
+            },
+            "optionalDependencies": {
+                "fsevents": "~2.3.3"
+            },
+            "peerDependencies": {
+                "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+                "jiti": ">=1.21.0",
+                "less": "*",
+                "lightningcss": "^1.21.0",
+                "sass": "*",
+                "sass-embedded": "*",
+                "stylus": "*",
+                "sugarss": "*",
+                "terser": "^5.16.0",
+                "tsx": "^4.8.1",
+                "yaml": "^2.4.2"
+            },
+            "peerDependenciesMeta": {
+                "@types/node": {
+                    "optional": true
+                },
+                "jiti": {
+                    "optional": true
+                },
+                "less": {
+                    "optional": true
+                },
+                "lightningcss": {
+                    "optional": true
+                },
+                "sass": {
+                    "optional": true
+                },
+                "sass-embedded": {
+                    "optional": true
+                },
+                "stylus": {
+                    "optional": true
+                },
+                "sugarss": {
+                    "optional": true
+                },
+                "terser": {
+                    "optional": true
+                },
+                "tsx": {
+                    "optional": true
+                },
+                "yaml": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/vite-node": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+            "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "cac": "^6.7.14",
+                "debug": "^4.4.1",
+                "es-module-lexer": "^1.7.0",
+                "pathe": "^2.0.3",
+                "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+            },
+            "bin": {
+                "vite-node": "vite-node.mjs"
+            },
+            "engines": {
+                "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/vite-plugin-full-reload": {
+            "version": "1.2.0",
+            "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz",
+            "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "picocolors": "^1.0.0",
+                "picomatch": "^2.3.1"
+            }
+        },
+        "node_modules/vite-plugin-webpackchunkname": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/vite-plugin-webpackchunkname/-/vite-plugin-webpackchunkname-1.0.3.tgz",
+            "integrity": "sha512-88lt6IrgCumnf4Up8eyaSJbmo4V0ZIaR4M94fbZvGGmK2aWMmPGVsiFBszYE7Kq04I9tGjLFnyremn+KEgEGyw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@rollup/plugin-alias": "*",
+                "@rollup/pluginutils": "*",
+                "es-module-lexer": "*",
+                "magic-string": "*"
+            }
+        },
+        "node_modules/vite/node_modules/fdir": {
+            "version": "6.4.6",
+            "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
+            "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
+            "dev": true,
+            "license": "MIT",
+            "peerDependencies": {
+                "picomatch": "^3 || ^4"
+            },
+            "peerDependenciesMeta": {
+                "picomatch": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/vite/node_modules/picomatch": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+            "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/jonschlinkert"
+            }
+        },
+        "node_modules/vitest": {
+            "version": "3.2.4",
+            "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+            "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@types/chai": "^5.2.2",
+                "@vitest/expect": "3.2.4",
+                "@vitest/mocker": "3.2.4",
+                "@vitest/pretty-format": "^3.2.4",
+                "@vitest/runner": "3.2.4",
+                "@vitest/snapshot": "3.2.4",
+                "@vitest/spy": "3.2.4",
+                "@vitest/utils": "3.2.4",
+                "chai": "^5.2.0",
+                "debug": "^4.4.1",
+                "expect-type": "^1.2.1",
+                "magic-string": "^0.30.17",
+                "pathe": "^2.0.3",
+                "picomatch": "^4.0.2",
+                "std-env": "^3.9.0",
+                "tinybench": "^2.9.0",
+                "tinyexec": "^0.3.2",
+                "tinyglobby": "^0.2.14",
+                "tinypool": "^1.1.1",
+                "tinyrainbow": "^2.0.0",
+                "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+                "vite-node": "3.2.4",
+                "why-is-node-running": "^2.3.0"
+            },
+            "bin": {
+                "vitest": "vitest.mjs"
+            },
+            "engines": {
+                "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            },
+            "peerDependencies": {
+                "@edge-runtime/vm": "*",
+                "@types/debug": "^4.1.12",
+                "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+                "@vitest/browser": "3.2.4",
+                "@vitest/ui": "3.2.4",
+                "happy-dom": "*",
+                "jsdom": "*"
+            },
+            "peerDependenciesMeta": {
+                "@edge-runtime/vm": {
+                    "optional": true
+                },
+                "@types/debug": {
+                    "optional": true
+                },
+                "@types/node": {
+                    "optional": true
+                },
+                "@vitest/browser": {
+                    "optional": true
+                },
+                "@vitest/ui": {
+                    "optional": true
+                },
+                "happy-dom": {
+                    "optional": true
+                },
+                "jsdom": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/vitest/node_modules/picomatch": {
+            "version": "4.0.2",
+            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+            "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/jonschlinkert"
+            }
+        },
         "node_modules/vm-browserify": {
             "version": "1.1.2",
             "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
             "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
         },
         "node_modules/w3c-xmlserializer": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
-            "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==",
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+            "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "xml-name-validator": "^4.0.0"
+                "xml-name-validator": "^5.0.0"
             },
             "engines": {
-                "node": ">=14"
-            }
-        },
-        "node_modules/walker": {
-            "version": "1.0.8",
-            "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
-            "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
-            "dev": true,
-            "dependencies": {
-                "makeerror": "1.0.12"
+                "node": ">=18"
             }
         },
         "node_modules/warning": {
             "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
             "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
             "dev": true,
+            "license": "BSD-2-Clause",
             "engines": {
                 "node": ">=12"
             }
             }
         },
         "node_modules/whatwg-encoding": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz",
-            "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==",
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+            "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "iconv-lite": "0.6.3"
             },
             "engines": {
-                "node": ">=12"
+                "node": ">=18"
             }
         },
         "node_modules/whatwg-encoding/node_modules/iconv-lite": {
             "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
             "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
                 "safer-buffer": ">= 2.1.2 < 3.0.0"
             },
             }
         },
         "node_modules/whatwg-mimetype": {
-            "version": "3.0.0",
-            "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
-            "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+            "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
             "dev": true,
+            "license": "MIT",
             "engines": {
-                "node": ">=12"
+                "node": ">=18"
             }
         },
         "node_modules/whatwg-url": {
-            "version": "11.0.0",
-            "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
-            "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
+            "version": "14.2.0",
+            "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+            "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
             "dev": true,
+            "license": "MIT",
             "dependencies": {
-                "tr46": "^3.0.0",
+                "tr46": "^5.1.0",
                 "webidl-conversions": "^7.0.0"
             },
             "engines": {
-                "node": ">=12"
+                "node": ">=18"
             }
         },
         "node_modules/which": {
                 "url": "https://github.com/sponsors/ljharb"
             }
         },
+        "node_modules/why-is-node-running": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+            "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "siginfo": "^2.0.0",
+                "stackback": "0.0.2"
+            },
+            "bin": {
+                "why-is-node-running": "cli.js"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/wildcard": {
             "version": "2.0.1",
             "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
             "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
             "dev": true
         },
-        "node_modules/write-file-atomic": {
-            "version": "4.0.2",
-            "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz",
-            "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==",
-            "dev": true,
-            "dependencies": {
-                "imurmurhash": "^0.1.4",
-                "signal-exit": "^3.0.7"
-            },
-            "engines": {
-                "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
-            }
-        },
         "node_modules/ws": {
             "version": "8.18.0",
             "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
             }
         },
         "node_modules/xml-name-validator": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
-            "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+            "version": "5.0.0",
+            "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+            "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
             "dev": true,
+            "license": "Apache-2.0",
             "engines": {
-                "node": ">=12"
+                "node": ">=18"
             }
         },
         "node_modules/xmlchars": {
             "version": "2.2.0",
             "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
             "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
-            "dev": true
+            "dev": true,
+            "license": "MIT"
         },
         "node_modules/xtend": {
             "version": "4.0.2",
             "dev": true
         },
         "node_modules/yaml": {
-            "version": "1.10.2",
-            "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
-            "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+            "version": "2.8.0",
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
+            "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
             "dev": true,
+            "license": "ISC",
+            "bin": {
+                "yaml": "bin.mjs"
+            },
             "engines": {
-                "node": ">= 6"
+                "node": ">= 14.6"
             }
         },
         "node_modules/yargs": {
index 2fb3fae49fd6cfe418c681fa5fa90aeaecbb8518..7e70e2d506e1f56baaaee846c4f2422a45678377 100644 (file)
@@ -1,98 +1,10 @@
 {
     "private": true,
     "scripts": {
-        "dev": "npm run development",
-        "development": "mix",
-        "watch": "mix watch",
-        "watch-poll": "mix watch -- --watch-options-poll=1000",
-        "hot": "mix watch --hot",
-        "prod": "npm run production",
-        "production": "mix --production",
-        "test": "NODE_ENV=test npx jest",
-        "test-watch": "npm run test -- --watch --notify"
-    },
-    "eslintConfig": {
-        "env": {
-            "browser": true,
-            "es6": true,
-            "node": true
-        },
-        "extends": [
-            "eslint:recommended",
-            "plugin:import/recommended",
-            "plugin:react/recommended"
-        ],
-        "parser": "@babel/eslint-parser",
-        "parserOptions": {
-            "babelOptions": {
-                "presets": [
-                    "@babel/preset-react"
-                ]
-            },
-            "ecmaFeatures": {
-                "jsx": true
-            },
-            "ecmaVersion": 2018,
-            "requireConfigFile": false,
-            "sourceType": "module"
-        },
-        "rules": {
-            "import/no-named-as-default-member": 0,
-            "max-len": [
-                "warn",
-                {
-                    "code": 100,
-                    "tabWidth": 4
-                }
-            ],
-            "no-use-before-define": "error",
-            "no-extra-parens": [
-                "warn",
-                "all",
-                {
-                    "nestedBinaryExpressions": false
-                }
-            ],
-            "no-mixed-operators": "error",
-            "no-trailing-spaces": "error",
-            "semi": [
-                "error",
-                "always"
-            ]
-        },
-        "overrides": [
-            {
-                "files": [
-                    "**/*.test.js"
-                ],
-                "env": {
-                    "jest": true
-                },
-                "settings": {
-                    "import/resolver": {
-                        "node": {
-                            "paths": [
-                                "resources/js"
-                            ]
-                        }
-                    }
-                }
-            }
-        ]
-    },
-    "jest": {
-        "moduleDirectories": [
-            "node_modules",
-            "resources/js"
-        ],
-        "roots": [
-            "<rootDir>/resources/js",
-            "<rootDir>/tests/js"
-        ],
-        "setupFilesAfterEnv": [
-            "<rootDir>/resources/js/setup-jest.js"
-        ],
-        "testEnvironment": "jsdom"
+        "dev": "vite",
+        "build": "vite build",
+        "test": "vitest --run",
+        "test-watch": "vitest"
     },
     "devDependencies": {
         "@babel/eslint-parser": "^7.22.11",
         "@tailwindcss/forms": "^0.5.6",
         "@testing-library/jest-dom": "^6.4.2",
         "@testing-library/react": "^14.2.1",
+        "@vitejs/plugin-react": "^4.5.2",
         "alpinejs": "^3.4.2",
         "autoprefixer": "^10.4.2",
         "axios": "^1.5.0",
-        "babel-jest": "^29.7.0",
         "bootstrap": "^5.1.3",
         "eslint": "^8.10.0",
         "eslint-plugin-import": "^2.25.4",
         "eslint-plugin-react": "^7.29.3",
-        "jest": "^29.7.0",
-        "jest-environment-jsdom": "^29.7.0",
+        "globals": "^16.2.0",
+        "jsdom": "^26.1.0",
         "laravel-mix": "^6.0.6",
+        "laravel-vite-plugin": "^1.3.0",
         "lodash": "^4.17.19",
         "postcss": "^8.4.6",
         "postcss-import": "^15.1.0",
         "resolve-url-loader": "^5.0.0",
         "sass": "^1.32.11",
         "sass-loader": "^13.3.2",
-        "tailwindcss": "^3.0.18"
+        "tailwindcss": "^3.0.18",
+        "vite": "^6.3.5",
+        "vite-plugin-webpackchunkname": "^1.0.3",
+        "vitest": "^3.2.4"
     },
     "dependencies": {
         "@codemirror/lang-html": "^6.4.5",
         "recharts": "^2.1.9",
         "toastr": "^2.1.4",
         "yup": "^1.2.0"
-    }
+    },
+    "type": "module"
 }
diff --git a/resources/js/app/Footer.js b/resources/js/app/Footer.js
deleted file mode 100644 (file)
index e3e58bd..0000000
+++ /dev/null
@@ -1,167 +0,0 @@
-import React from 'react';
-import { Col, Nav, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-import { LinkContainer } from 'react-router-bootstrap';
-
-import PrivacyDialog from './PrivacyDialog';
-
-const Footer = () => {
-       const [showDialog, setShowDialog] = React.useState(false);
-
-       const { t } = useTranslation();
-
-       return <div className="bg-dark mt-5 px-3 py-5">
-               <Row>
-                       <Col md={4}>
-                               <h5>{t('footer.competitions')}</h5>
-                               <Nav as="ul" className="flex-column">
-                                       <Nav.Item as="li">
-                                               <LinkContainer to="/tournaments/6">
-                                                       <Nav.Link className="p-0 text-muted" href="/tournaments/6">
-                                                               {t('footer.sdw')}
-                                                       </Nav.Link>
-                                               </LinkContainer>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <LinkContainer to="/tournaments/7">
-                                                       <Nav.Link className="p-0 text-muted" href="/tournaments/7">
-                                                               {t('footer.circus')}
-                                                       </Nav.Link>
-                                               </LinkContainer>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <LinkContainer to="/schedule">
-                                                       <Nav.Link className="p-0 text-muted" href="/schedule">
-                                                               {t('footer.schedule')}
-                                                       </Nav.Link>
-                                               </LinkContainer>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <LinkContainer to="/events">
-                                                       <Nav.Link className="p-0 text-muted" href="/events">
-                                                               {t('footer.events')}
-                                                       </Nav.Link>
-                                               </LinkContainer>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://alttprasyncs.com/"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.alttprasyncs')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://smz3asyncs.com/"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.smz3asyncs')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                               </Nav>
-                       </Col>
-                       <Col md={4}>
-                               <h5>{t('footer.resources')}</h5>
-                               <Nav as="ul" className="flex-column">
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://alttp-wiki.net/"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.alttpwiki')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <LinkContainer to="/tech">
-                                                       <Nav.Link className="p-0 text-muted" href="/tech">
-                                                               {t('footer.tech')}
-                                                       </Nav.Link>
-                                               </LinkContainer>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <LinkContainer to="/map">
-                                                       <Nav.Link className="p-0 text-muted" href="/map">
-                                                               {t('footer.map')}
-                                                       </Nav.Link>
-                                               </LinkContainer>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://glitchmaps.mfns.dev/"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.muffins')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://wiki.supermetroid.run/"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.smwiki')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                               </Nav>
-                       </Col>
-                       <Col md={4}>
-                               <h5>{t('footer.info')}</h5>
-                               <Nav as="ul" className="flex-column">
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       onClick={() => { setShowDialog(true); }}
-                                               >
-                                                       {t('footer.privacy')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://discord.gg/5zuANcS"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.alttpde')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://discord.com/invite/GGdrbnQmVs"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.smd')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://discord.gg/yVdTkEZhk6"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.stepladder')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                                       <Nav.Item as="li">
-                                               <Nav.Link
-                                                       className="p-0 text-muted"
-                                                       href="https://discord.gg/cx6nZkekXz"
-                                                       target="_blank"
-                                               >
-                                                       {t('footer.restreamCentral')}
-                                               </Nav.Link>
-                                       </Nav.Item>
-                               </Nav>
-                       </Col>
-               </Row>
-               <p className="pt-5 text-center text-muted">{t('footer.contact')}</p>
-               <PrivacyDialog onHide={() => { setShowDialog(false); }} show={showDialog} />
-       </div>;
-};
-
-export default Footer;
diff --git a/resources/js/app/Footer.jsx b/resources/js/app/Footer.jsx
new file mode 100644 (file)
index 0000000..e3e58bd
--- /dev/null
@@ -0,0 +1,167 @@
+import React from 'react';
+import { Col, Nav, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import { LinkContainer } from 'react-router-bootstrap';
+
+import PrivacyDialog from './PrivacyDialog';
+
+const Footer = () => {
+       const [showDialog, setShowDialog] = React.useState(false);
+
+       const { t } = useTranslation();
+
+       return <div className="bg-dark mt-5 px-3 py-5">
+               <Row>
+                       <Col md={4}>
+                               <h5>{t('footer.competitions')}</h5>
+                               <Nav as="ul" className="flex-column">
+                                       <Nav.Item as="li">
+                                               <LinkContainer to="/tournaments/6">
+                                                       <Nav.Link className="p-0 text-muted" href="/tournaments/6">
+                                                               {t('footer.sdw')}
+                                                       </Nav.Link>
+                                               </LinkContainer>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <LinkContainer to="/tournaments/7">
+                                                       <Nav.Link className="p-0 text-muted" href="/tournaments/7">
+                                                               {t('footer.circus')}
+                                                       </Nav.Link>
+                                               </LinkContainer>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <LinkContainer to="/schedule">
+                                                       <Nav.Link className="p-0 text-muted" href="/schedule">
+                                                               {t('footer.schedule')}
+                                                       </Nav.Link>
+                                               </LinkContainer>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <LinkContainer to="/events">
+                                                       <Nav.Link className="p-0 text-muted" href="/events">
+                                                               {t('footer.events')}
+                                                       </Nav.Link>
+                                               </LinkContainer>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://alttprasyncs.com/"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.alttprasyncs')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://smz3asyncs.com/"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.smz3asyncs')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                               </Nav>
+                       </Col>
+                       <Col md={4}>
+                               <h5>{t('footer.resources')}</h5>
+                               <Nav as="ul" className="flex-column">
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://alttp-wiki.net/"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.alttpwiki')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <LinkContainer to="/tech">
+                                                       <Nav.Link className="p-0 text-muted" href="/tech">
+                                                               {t('footer.tech')}
+                                                       </Nav.Link>
+                                               </LinkContainer>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <LinkContainer to="/map">
+                                                       <Nav.Link className="p-0 text-muted" href="/map">
+                                                               {t('footer.map')}
+                                                       </Nav.Link>
+                                               </LinkContainer>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://glitchmaps.mfns.dev/"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.muffins')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://wiki.supermetroid.run/"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.smwiki')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                               </Nav>
+                       </Col>
+                       <Col md={4}>
+                               <h5>{t('footer.info')}</h5>
+                               <Nav as="ul" className="flex-column">
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       onClick={() => { setShowDialog(true); }}
+                                               >
+                                                       {t('footer.privacy')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://discord.gg/5zuANcS"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.alttpde')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://discord.com/invite/GGdrbnQmVs"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.smd')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://discord.gg/yVdTkEZhk6"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.stepladder')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                                       <Nav.Item as="li">
+                                               <Nav.Link
+                                                       className="p-0 text-muted"
+                                                       href="https://discord.gg/cx6nZkekXz"
+                                                       target="_blank"
+                                               >
+                                                       {t('footer.restreamCentral')}
+                                               </Nav.Link>
+                                       </Nav.Item>
+                               </Nav>
+                       </Col>
+               </Row>
+               <p className="pt-5 text-center text-muted">{t('footer.contact')}</p>
+               <PrivacyDialog onHide={() => { setShowDialog(false); }} show={showDialog} />
+       </div>;
+};
+
+export default Footer;
diff --git a/resources/js/app/FullLayout.js b/resources/js/app/FullLayout.js
deleted file mode 100644 (file)
index 1771a07..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from 'react';
-import { Outlet } from 'react-router-dom';
-
-import Footer from './Footer';
-import Header from './Header';
-
-const FullLayout = () => <>
-       <header>
-               <Header />
-       </header>
-       <main>
-               <Outlet />
-       </main>
-       <footer>
-               <Footer />
-       </footer>
-</>;
-
-export default FullLayout;
diff --git a/resources/js/app/FullLayout.jsx b/resources/js/app/FullLayout.jsx
new file mode 100644 (file)
index 0000000..1771a07
--- /dev/null
@@ -0,0 +1,19 @@
+import React from 'react';
+import { Outlet } from 'react-router-dom';
+
+import Footer from './Footer';
+import Header from './Header';
+
+const FullLayout = () => <>
+       <header>
+               <Header />
+       </header>
+       <main>
+               <Outlet />
+       </main>
+       <footer>
+               <Footer />
+       </footer>
+</>;
+
+export default FullLayout;
diff --git a/resources/js/app/Header.js b/resources/js/app/Header.js
deleted file mode 100644 (file)
index b12682b..0000000
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from 'react';
-import { Container, Nav, Navbar } from 'react-bootstrap';
-import { LinkContainer } from 'react-router-bootstrap';
-import { useLocation } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-
-import LanguageSwitcher from './LanguageSwitcher';
-import User from './User';
-import Icon from '../components/common/Icon';
-
-const Header = () => {
-       const { pathname } = useLocation();
-       const { t } = useTranslation();
-
-       return <Navbar id="header" bg="dark" expand="md" variant="dark">
-               <Container fluid>
-                       <LinkContainer to="/">
-                               <Navbar.Brand>
-                                       ALttP
-                               </Navbar.Brand>
-                       </LinkContainer>
-                       <Navbar.Toggle aria-controls="header-nav" label={t('button.menu')}>
-                               <Icon.MENU title="" />
-                       </Navbar.Toggle>
-                       <Navbar.Collapse id="header-nav">
-                               <Nav activeKey={pathname}>
-                                       <LinkContainer to="/tournaments/6">
-                                               <Nav.Link href="/tournaments/6">
-                                                       {t('menu.sdw')}
-                                               </Nav.Link>
-                                       </LinkContainer>
-                                       <LinkContainer to="/tournaments/7">
-                                               <Nav.Link href="/tournaments/7">
-                                                       {t('menu.circus')}
-                                               </Nav.Link>
-                                       </LinkContainer>
-                               </Nav>
-                               <Nav activeKey={pathname} className="ms-auto">
-                                       <LinkContainer to="/tech">
-                                               <Nav.Link href="/tech">
-                                                       {t('menu.tech')}
-                                               </Nav.Link>
-                                       </LinkContainer>
-                                       <LinkContainer to="/map/lw">
-                                               <Nav.Link href="/map/lw">
-                                                       {t('menu.map')}
-                                               </Nav.Link>
-                                       </LinkContainer>
-                               </Nav>
-                               <div className="d-flex align-items-center">
-                                       <Navbar.Text className="mx-2">
-                                               <LanguageSwitcher />
-                                       </Navbar.Text>
-                                       <User />
-                               </div>
-                       </Navbar.Collapse>
-               </Container>
-       </Navbar>;
-};
-
-export default Header;
diff --git a/resources/js/app/Header.jsx b/resources/js/app/Header.jsx
new file mode 100644 (file)
index 0000000..b12682b
--- /dev/null
@@ -0,0 +1,61 @@
+import React from 'react';
+import { Container, Nav, Navbar } from 'react-bootstrap';
+import { LinkContainer } from 'react-router-bootstrap';
+import { useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+
+import LanguageSwitcher from './LanguageSwitcher';
+import User from './User';
+import Icon from '../components/common/Icon';
+
+const Header = () => {
+       const { pathname } = useLocation();
+       const { t } = useTranslation();
+
+       return <Navbar id="header" bg="dark" expand="md" variant="dark">
+               <Container fluid>
+                       <LinkContainer to="/">
+                               <Navbar.Brand>
+                                       ALttP
+                               </Navbar.Brand>
+                       </LinkContainer>
+                       <Navbar.Toggle aria-controls="header-nav" label={t('button.menu')}>
+                               <Icon.MENU title="" />
+                       </Navbar.Toggle>
+                       <Navbar.Collapse id="header-nav">
+                               <Nav activeKey={pathname}>
+                                       <LinkContainer to="/tournaments/6">
+                                               <Nav.Link href="/tournaments/6">
+                                                       {t('menu.sdw')}
+                                               </Nav.Link>
+                                       </LinkContainer>
+                                       <LinkContainer to="/tournaments/7">
+                                               <Nav.Link href="/tournaments/7">
+                                                       {t('menu.circus')}
+                                               </Nav.Link>
+                                       </LinkContainer>
+                               </Nav>
+                               <Nav activeKey={pathname} className="ms-auto">
+                                       <LinkContainer to="/tech">
+                                               <Nav.Link href="/tech">
+                                                       {t('menu.tech')}
+                                               </Nav.Link>
+                                       </LinkContainer>
+                                       <LinkContainer to="/map/lw">
+                                               <Nav.Link href="/map/lw">
+                                                       {t('menu.map')}
+                                               </Nav.Link>
+                                       </LinkContainer>
+                               </Nav>
+                               <div className="d-flex align-items-center">
+                                       <Navbar.Text className="mx-2">
+                                               <LanguageSwitcher />
+                                       </Navbar.Text>
+                                       <User />
+                               </div>
+                       </Navbar.Collapse>
+               </Container>
+       </Navbar>;
+};
+
+export default Header;
diff --git a/resources/js/app/LanguageSwitcher.js b/resources/js/app/LanguageSwitcher.js
deleted file mode 100644 (file)
index 52efaf5..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-import axios from 'axios';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import Icon from '../components/common/Icon';
-import { useUser } from '../hooks/user';
-import i18n from '../i18n';
-
-const setLanguage = (user, language) => {
-       i18n.changeLanguage(language);
-       if (user) {
-               axios.post('/api/users/set-language', { language });
-       }
-};
-
-const LanguageSwitcher = () => {
-       const { user } = useUser();
-
-       return <Button
-               className="text-reset"
-               href={`?lng=${i18n.language === 'de' ? 'en' : 'de'}`}
-               onClick={e => {
-                       setLanguage(user, i18n.language === 'de' ? 'en' : 'de');
-                       e.preventDefault();
-               }}
-               title={i18n.language === 'de' ? 'Switch to english' : 'Auf deutsch wechseln'}
-               variant="outline-secondary"
-       >
-               <Icon.LANGUAGE />
-               {' '}
-               {i18n.language === 'de' ? 'Deutsch' : 'English'}
-       </Button>;
-};
-
-export default withTranslation()(LanguageSwitcher);
diff --git a/resources/js/app/LanguageSwitcher.jsx b/resources/js/app/LanguageSwitcher.jsx
new file mode 100644 (file)
index 0000000..52efaf5
--- /dev/null
@@ -0,0 +1,36 @@
+import axios from 'axios';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Icon from '../components/common/Icon';
+import { useUser } from '../hooks/user';
+import i18n from '../i18n';
+
+const setLanguage = (user, language) => {
+       i18n.changeLanguage(language);
+       if (user) {
+               axios.post('/api/users/set-language', { language });
+       }
+};
+
+const LanguageSwitcher = () => {
+       const { user } = useUser();
+
+       return <Button
+               className="text-reset"
+               href={`?lng=${i18n.language === 'de' ? 'en' : 'de'}`}
+               onClick={e => {
+                       setLanguage(user, i18n.language === 'de' ? 'en' : 'de');
+                       e.preventDefault();
+               }}
+               title={i18n.language === 'de' ? 'Switch to english' : 'Auf deutsch wechseln'}
+               variant="outline-secondary"
+       >
+               <Icon.LANGUAGE />
+               {' '}
+               {i18n.language === 'de' ? 'Deutsch' : 'English'}
+       </Button>;
+};
+
+export default withTranslation()(LanguageSwitcher);
diff --git a/resources/js/app/PrivacyDialog.js b/resources/js/app/PrivacyDialog.js
deleted file mode 100644 (file)
index 9cf96b3..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-const PrivacyDialog = ({
-       onHide,
-       show,
-}) => {
-       const { t } = useTranslation();
-
-       return <Modal onHide={onHide} show={show}>
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('privacy.heading')}
-                       </Modal.Title>
-               </Modal.Header>
-               <Modal.Body>
-                       <p>{t('privacy.p1')}</p>
-                       <p>{t('privacy.p2')}</p>
-                       <p>{t('privacy.p3')}</p>
-               </Modal.Body>
-               <Modal.Footer>
-                       <Button onClick={onHide} variant="secondary">
-                               {t('button.close')}
-                       </Button>
-               </Modal.Footer>
-       </Modal>;
-};
-
-PrivacyDialog.propTypes = {
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-};
-
-export default PrivacyDialog;
diff --git a/resources/js/app/PrivacyDialog.jsx b/resources/js/app/PrivacyDialog.jsx
new file mode 100644 (file)
index 0000000..9cf96b3
--- /dev/null
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const PrivacyDialog = ({
+       onHide,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('privacy.heading')}
+                       </Modal.Title>
+               </Modal.Header>
+               <Modal.Body>
+                       <p>{t('privacy.p1')}</p>
+                       <p>{t('privacy.p2')}</p>
+                       <p>{t('privacy.p3')}</p>
+               </Modal.Body>
+               <Modal.Footer>
+                       <Button onClick={onHide} variant="secondary">
+                               {t('button.close')}
+                       </Button>
+               </Modal.Footer>
+       </Modal>;
+};
+
+PrivacyDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+};
+
+export default PrivacyDialog;
diff --git a/resources/js/app/Routes.js b/resources/js/app/Routes.js
deleted file mode 100644 (file)
index abfeee4..0000000
+++ /dev/null
@@ -1,180 +0,0 @@
-import React from 'react';
-import {
-       createBrowserRouter,
-       createRoutesFromElements,
-       Navigate,
-       Route,
-       RouterProvider,
-} from 'react-router-dom';
-
-import FullLayout from './FullLayout';
-import Front from '../pages/Front';
-import Technique from '../pages/Technique';
-import Techniques from '../pages/Techniques';
-import User from '../pages/User';
-
-const router = createBrowserRouter(
-       createRoutesFromElements(
-               <Route>
-                       <Route element={<FullLayout />}>
-                               <Route
-                                       path="discord-bot"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "admin" */
-                                               '../pages/DiscordBot'
-                                       )}
-                               />
-                               <Route
-                                       path="dungeons"
-                                       element={<Techniques namespace="dungeons" type="dungeon" />}
-                               />
-                               <Route
-                                       path="dungeons/:name"
-                                       element={<Technique basepath="dungeons" type="dungeon" />}
-                               />
-                               <Route
-                                       path="events"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "events" */
-                                               '../pages/Events'
-                                       )}
-                               />
-                               <Route
-                                       path="events/:name"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "events" */
-                                               '../pages/Event'
-                                       )}
-                               />
-                               <Route
-                                       path="h/:hash"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "seeds" */
-                                               '../pages/AlttpSeed'
-                                       )}
-                               />
-                               <Route
-                                       path="horstielog"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "horstie" */
-                                               '../pages/HorstieLog'
-                                       )}
-                               />
-                               <Route
-                                       path="locations"
-                                       element={<Techniques namespace="locations" type="location" />}
-                               />
-                               <Route
-                                       path="locations/:name"
-                                       element={<Technique basepath="locations" type="location" />}
-                               />
-                               <Route path="map">
-                                       <Route index element={<Navigate replace to="lw" />} />
-                                       <Route
-                                               path=":activeMap"
-                                               lazy={() => import(
-                                                       /* webpackChunkName: "map" */
-                                                       '../pages/Map'
-                                               )}
-                                       />
-                               </Route>
-                               <Route
-                                       path="modes"
-                                       element={<Techniques namespace="modes" type="mode" />}
-                               />
-                               <Route
-                                       path="modes/:name"
-                                       element={<Technique basepath="modes" type="mode" />}
-                               />
-                               <Route
-                                       path="rulesets"
-                                       element={<Techniques namespace="rulesets" type="ruleset" />}
-                                       />
-                               <Route
-                                       path="rulesets/:name"
-                                       element={<Technique basepath="rulesets" type="ruleset" />}
-                               />
-                               <Route
-                                       path="schedule"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "events" */
-                                               '../pages/Schedule'
-                                       )}
-                               />
-                               <Route
-                                       path="tech"
-                                       element={<Techniques namespace="techniques" type="tech" />}
-                               />
-                               <Route
-                                       path="tech/:name"
-                                       element={<Technique basepath="tech" type="tech" />}
-                               />
-                               <Route
-                                       path="tournaments/:id"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "tournament" */
-                                               '../pages/Tournament'
-                                       )}
-                               />
-                               <Route
-                                       path="twitch-bot"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "admin" */
-                                               '../pages/TwitchBot'
-                                       )}
-                               />
-                               <Route
-                                       path="twitch-legal"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "twitch" */
-                                               '../pages/TwitchLegal'
-                                       )}
-                               />
-                               <Route path="users/:id" element={<User />} />
-                               <Route path="/" element={<Front />} />
-                               <Route path="*" element={<Navigate to="/" />} />
-                       </Route>
-                       <Route
-                               path="doors-tracker"
-                               lazy={() => import(
-                                       /* webpackChunkName: "tracker" */
-                                       '../pages/DoorsTracker'
-                               )}
-                       />
-                       <Route path="guessing-game">
-                               <Route
-                                       path="controls/:channelId?"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "guessing" */
-                                               '../pages/GuessingGameControls'
-                                       )}
-                               />
-                               <Route
-                                       path="monitor/:key"
-                                       lazy={() => import(
-                                               /* webpackChunkName: "guessing" */
-                                               '../pages/GuessingGameMonitor'
-                                       )}
-                               />
-                       </Route>
-                       <Route
-                               path="tracker"
-                               lazy={() => import(
-                                       /* webpackChunkName: "tracker" */
-                                       '../pages/Tracker'
-                               )}
-                       />
-                       <Route
-                               path="zootr-mixed-pools-tracker"
-                               lazy={() => import(
-                                       /* webpackChunkName: "zootr" */
-                                       '../pages/ZootrMixedPoolsTracker'
-                               )}
-                       />
-               </Route>
-       )
-);
-
-const AppRoutes = () => <RouterProvider router={router} />;
-
-export default AppRoutes;
diff --git a/resources/js/app/Routes.jsx b/resources/js/app/Routes.jsx
new file mode 100644 (file)
index 0000000..abfeee4
--- /dev/null
@@ -0,0 +1,180 @@
+import React from 'react';
+import {
+       createBrowserRouter,
+       createRoutesFromElements,
+       Navigate,
+       Route,
+       RouterProvider,
+} from 'react-router-dom';
+
+import FullLayout from './FullLayout';
+import Front from '../pages/Front';
+import Technique from '../pages/Technique';
+import Techniques from '../pages/Techniques';
+import User from '../pages/User';
+
+const router = createBrowserRouter(
+       createRoutesFromElements(
+               <Route>
+                       <Route element={<FullLayout />}>
+                               <Route
+                                       path="discord-bot"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "admin" */
+                                               '../pages/DiscordBot'
+                                       )}
+                               />
+                               <Route
+                                       path="dungeons"
+                                       element={<Techniques namespace="dungeons" type="dungeon" />}
+                               />
+                               <Route
+                                       path="dungeons/:name"
+                                       element={<Technique basepath="dungeons" type="dungeon" />}
+                               />
+                               <Route
+                                       path="events"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "events" */
+                                               '../pages/Events'
+                                       )}
+                               />
+                               <Route
+                                       path="events/:name"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "events" */
+                                               '../pages/Event'
+                                       )}
+                               />
+                               <Route
+                                       path="h/:hash"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "seeds" */
+                                               '../pages/AlttpSeed'
+                                       )}
+                               />
+                               <Route
+                                       path="horstielog"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "horstie" */
+                                               '../pages/HorstieLog'
+                                       )}
+                               />
+                               <Route
+                                       path="locations"
+                                       element={<Techniques namespace="locations" type="location" />}
+                               />
+                               <Route
+                                       path="locations/:name"
+                                       element={<Technique basepath="locations" type="location" />}
+                               />
+                               <Route path="map">
+                                       <Route index element={<Navigate replace to="lw" />} />
+                                       <Route
+                                               path=":activeMap"
+                                               lazy={() => import(
+                                                       /* webpackChunkName: "map" */
+                                                       '../pages/Map'
+                                               )}
+                                       />
+                               </Route>
+                               <Route
+                                       path="modes"
+                                       element={<Techniques namespace="modes" type="mode" />}
+                               />
+                               <Route
+                                       path="modes/:name"
+                                       element={<Technique basepath="modes" type="mode" />}
+                               />
+                               <Route
+                                       path="rulesets"
+                                       element={<Techniques namespace="rulesets" type="ruleset" />}
+                                       />
+                               <Route
+                                       path="rulesets/:name"
+                                       element={<Technique basepath="rulesets" type="ruleset" />}
+                               />
+                               <Route
+                                       path="schedule"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "events" */
+                                               '../pages/Schedule'
+                                       )}
+                               />
+                               <Route
+                                       path="tech"
+                                       element={<Techniques namespace="techniques" type="tech" />}
+                               />
+                               <Route
+                                       path="tech/:name"
+                                       element={<Technique basepath="tech" type="tech" />}
+                               />
+                               <Route
+                                       path="tournaments/:id"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "tournament" */
+                                               '../pages/Tournament'
+                                       )}
+                               />
+                               <Route
+                                       path="twitch-bot"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "admin" */
+                                               '../pages/TwitchBot'
+                                       )}
+                               />
+                               <Route
+                                       path="twitch-legal"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "twitch" */
+                                               '../pages/TwitchLegal'
+                                       )}
+                               />
+                               <Route path="users/:id" element={<User />} />
+                               <Route path="/" element={<Front />} />
+                               <Route path="*" element={<Navigate to="/" />} />
+                       </Route>
+                       <Route
+                               path="doors-tracker"
+                               lazy={() => import(
+                                       /* webpackChunkName: "tracker" */
+                                       '../pages/DoorsTracker'
+                               )}
+                       />
+                       <Route path="guessing-game">
+                               <Route
+                                       path="controls/:channelId?"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "guessing" */
+                                               '../pages/GuessingGameControls'
+                                       )}
+                               />
+                               <Route
+                                       path="monitor/:key"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "guessing" */
+                                               '../pages/GuessingGameMonitor'
+                                       )}
+                               />
+                       </Route>
+                       <Route
+                               path="tracker"
+                               lazy={() => import(
+                                       /* webpackChunkName: "tracker" */
+                                       '../pages/Tracker'
+                               )}
+                       />
+                       <Route
+                               path="zootr-mixed-pools-tracker"
+                               lazy={() => import(
+                                       /* webpackChunkName: "zootr" */
+                                       '../pages/ZootrMixedPoolsTracker'
+                               )}
+                       />
+               </Route>
+       )
+);
+
+const AppRoutes = () => <RouterProvider router={router} />;
+
+export default AppRoutes;
diff --git a/resources/js/app/User.js b/resources/js/app/User.js
deleted file mode 100644 (file)
index 402178a..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from 'react';
-import { Button, Nav } from 'react-bootstrap';
-import { LinkContainer } from 'react-router-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from '../components/common/Icon';
-import { useUser } from '../hooks/user';
-import { getAvatarUrl } from '../helpers/User';
-
-const User = () => {
-       const { t } = useTranslation();
-       const { logout, user } = useUser();
-
-       return user
-               ? <>
-                       <Nav className="ms-auto">
-                               <LinkContainer to={`/users/${user.id}`}>
-                                       <Nav.Link>
-                                               <img alt="" src={getAvatarUrl(user)} />
-                                               {user.username}
-                                               {user.discriminator && user.discriminator !== '0' ?
-                                                       <span className="text-muted">#{user.discriminator}</span>
-                                               : null}
-                                       </Nav.Link>
-                               </LinkContainer>
-                       </Nav>
-                       <Button
-                       className="ms-2"
-                               onClick={logout}
-                               title={t('button.logout')}
-                               variant="outline-secondary"
-                       >
-                               <Icon.LOGOUT title="" />
-                       </Button>
-               </>
-               : <Button
-                       className="ms-auto"
-                       href="/login"
-                       onClick={() => {
-                               if (location.pathname.length > 1) {
-                                       localStorage.setItem('returnPath', location.pathname.substr(1));
-                               }
-                       }}
-                       title={t('button.login')}
-                       variant="discord"
-               >
-                       <Icon.DISCORD />
-                       {' '}
-                       {t('button.login')}
-               </Button>;
-};
-
-export default User;
diff --git a/resources/js/app/User.jsx b/resources/js/app/User.jsx
new file mode 100644 (file)
index 0000000..402178a
--- /dev/null
@@ -0,0 +1,53 @@
+import React from 'react';
+import { Button, Nav } from 'react-bootstrap';
+import { LinkContainer } from 'react-router-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../components/common/Icon';
+import { useUser } from '../hooks/user';
+import { getAvatarUrl } from '../helpers/User';
+
+const User = () => {
+       const { t } = useTranslation();
+       const { logout, user } = useUser();
+
+       return user
+               ? <>
+                       <Nav className="ms-auto">
+                               <LinkContainer to={`/users/${user.id}`}>
+                                       <Nav.Link>
+                                               <img alt="" src={getAvatarUrl(user)} />
+                                               {user.username}
+                                               {user.discriminator && user.discriminator !== '0' ?
+                                                       <span className="text-muted">#{user.discriminator}</span>
+                                               : null}
+                                       </Nav.Link>
+                               </LinkContainer>
+                       </Nav>
+                       <Button
+                       className="ms-2"
+                               onClick={logout}
+                               title={t('button.logout')}
+                               variant="outline-secondary"
+                       >
+                               <Icon.LOGOUT title="" />
+                       </Button>
+               </>
+               : <Button
+                       className="ms-auto"
+                       href="/login"
+                       onClick={() => {
+                               if (location.pathname.length > 1) {
+                                       localStorage.setItem('returnPath', location.pathname.substr(1));
+                               }
+                       }}
+                       title={t('button.login')}
+                       variant="discord"
+               >
+                       <Icon.DISCORD />
+                       {' '}
+                       {t('button.login')}
+               </Button>;
+};
+
+export default User;
diff --git a/resources/js/app/index.js b/resources/js/app/index.js
deleted file mode 100644 (file)
index 385ed7d..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-
-import Routes from './Routes';
-import AlttpBaseRomProvider from '../helpers/AlttpBaseRomContext';
-import { SNESProvider } from '../hooks/snes';
-import { UserProvider } from '../hooks/user';
-import i18n from '../i18n';
-
-const App = () => {
-       const { t } = useTranslation();
-
-       React.useEffect(() => {
-               window.Echo.channel('App.Control')
-                       .listen('PleaseRefresh', () => {
-                               location.reload();
-                       });
-               return () => {
-                       window.Echo.leave('App.Control');
-               };
-       }, []);
-
-       return <AlttpBaseRomProvider>
-               <SNESProvider>
-                       <UserProvider>
-                               <Helmet>
-                                       <html lang={i18n.language} />
-                                       <title>{t('general.appName')}</title>
-                                       <meta name="description" content={t('general.appDescription')} />
-                               </Helmet>
-                               <Routes />
-                       </UserProvider>
-               </SNESProvider>
-       </AlttpBaseRomProvider>;
-};
-
-export default App;
diff --git a/resources/js/app/index.jsx b/resources/js/app/index.jsx
new file mode 100644 (file)
index 0000000..385ed7d
--- /dev/null
@@ -0,0 +1,38 @@
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+
+import Routes from './Routes';
+import AlttpBaseRomProvider from '../helpers/AlttpBaseRomContext';
+import { SNESProvider } from '../hooks/snes';
+import { UserProvider } from '../hooks/user';
+import i18n from '../i18n';
+
+const App = () => {
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               window.Echo.channel('App.Control')
+                       .listen('PleaseRefresh', () => {
+                               location.reload();
+                       });
+               return () => {
+                       window.Echo.leave('App.Control');
+               };
+       }, []);
+
+       return <AlttpBaseRomProvider>
+               <SNESProvider>
+                       <UserProvider>
+                               <Helmet>
+                                       <html lang={i18n.language} />
+                                       <title>{t('general.appName')}</title>
+                                       <meta name="description" content={t('general.appDescription')} />
+                               </Helmet>
+                               <Routes />
+                       </UserProvider>
+               </SNESProvider>
+       </AlttpBaseRomProvider>;
+};
+
+export default App;
index 27a32b42129afe28abd3f6120c1ccf4da76b8865..0ed0904e17edb549e1d3c6e3f018b0628b526f3a 100644 (file)
@@ -1,10 +1,7 @@
-window._ = require('lodash');
-
-try {
-    require('bootstrap');
-} catch (e) {
-       // well...
-}
+import axios from 'axios';
+import Echo from 'laravel-echo';
+import Pusher from 'pusher-js';
+import qs from 'qs';
 
 /**
  * We'll load the axios HTTP library which allows us to easily issue requests
@@ -12,11 +9,8 @@ try {
  * CSRF token as a header based on the value of the "XSRF" token cookie.
  */
 
-window.axios = require('axios');
-
+window.axios = axios;
 window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
-
-import qs from 'qs';
 window.axios.defaults.paramsSerializer = p => qs.stringify(p, { arrayFormat: 'brackets' });
 
 /**
@@ -25,13 +19,11 @@ window.axios.defaults.paramsSerializer = p => qs.stringify(p, { arrayFormat: 'br
  * allows your team to easily build robust real-time web applications.
  */
 
-import Echo from 'laravel-echo';
-
-window.Pusher = require('pusher-js');
+window.Pusher = Pusher;
 
 window.Echo = new Echo({
     broadcaster: 'reverb',
-    key: process.env.MIX_REVERB_APP_KEY,
+    key: import.meta.env.VITE_REVERB_APP_KEY,
     wsHost: window.location.hostname,
     wsPort: window.location.port,
     forceTLS: false,
diff --git a/resources/js/components/alttp-seeds/BaseRomButton.js b/resources/js/components/alttp-seeds/BaseRomButton.js
deleted file mode 100644 (file)
index e8954d5..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import i18n from '../../i18n';
-
-import { useAlttpBaseRom } from '../../helpers/AlttpBaseRomContext';
-
-const BaseRomButton = () => {
-       const { rom, setRom } = useAlttpBaseRom();
-
-       const handleFile = React.useCallback(async e => {
-               if (e.target.files.length != 1) {
-                       setRom(null);
-               } else {
-                       const buf = await e.target.files[0].arrayBuffer();
-                       setRom(buf);
-               }
-       }, [setRom]);
-
-       if (rom) return null;
-
-       return <span>
-               <input
-                       accept=".sfc"
-                       className="d-none"
-                       id="alttp.baseRom"
-                       onChange={handleFile}
-                       type="file"
-               />
-               <label htmlFor="alttp.baseRom">
-                       <Button as="span" variant="primary">
-                               {i18n.t('alttp.setBaseRom')}
-                       </Button>
-               </label>
-       </span>;
-};
-
-export default withTranslation()(BaseRomButton);
diff --git a/resources/js/components/alttp-seeds/BaseRomButton.jsx b/resources/js/components/alttp-seeds/BaseRomButton.jsx
new file mode 100644 (file)
index 0000000..e8954d5
--- /dev/null
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import i18n from '../../i18n';
+
+import { useAlttpBaseRom } from '../../helpers/AlttpBaseRomContext';
+
+const BaseRomButton = () => {
+       const { rom, setRom } = useAlttpBaseRom();
+
+       const handleFile = React.useCallback(async e => {
+               if (e.target.files.length != 1) {
+                       setRom(null);
+               } else {
+                       const buf = await e.target.files[0].arrayBuffer();
+                       setRom(buf);
+               }
+       }, [setRom]);
+
+       if (rom) return null;
+
+       return <span>
+               <input
+                       accept=".sfc"
+                       className="d-none"
+                       id="alttp.baseRom"
+                       onChange={handleFile}
+                       type="file"
+               />
+               <label htmlFor="alttp.baseRom">
+                       <Button as="span" variant="primary">
+                               {i18n.t('alttp.setBaseRom')}
+                       </Button>
+               </label>
+       </span>;
+};
+
+export default withTranslation()(BaseRomButton);
diff --git a/resources/js/components/alttp-seeds/Seed.js b/resources/js/components/alttp-seeds/Seed.js
deleted file mode 100644 (file)
index e804ae3..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-import FileSaver from 'file-saver';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Container, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import BaseRomButton from './BaseRomButton';
-import { useAlttpBaseRom } from '../../helpers/AlttpBaseRomContext';
-import BPS from '../../helpers/bps';
-import i18n from '../../i18n';
-
-const applyPatch = (rom, patch, filename) => {
-       try {
-               const bps = new BPS();
-               bps.setPatch(patch);
-               bps.setSource(rom);
-               const result = bps.applyPatch();
-               FileSaver.saveAs(new Blob([result], { type: 'application/octet-stream' }), filename);
-       } catch (e) {
-               toastr.error(i18n.t('alttpSeeds.patchError', { msg: e.message }));
-       }
-};
-
-const isDefaultSetting = () => false;
-
-const Seed = ({ onRetry, patch, seed }) => {
-       const { rom } = useAlttpBaseRom();
-
-       return <Container>
-               <h1>{i18n.t('alttpSeeds.heading')}</h1>
-               <Row>
-                       <Col md={{ order: 2 }}>
-                               {rom ?
-                                       <Button
-                                               disabled={!seed || seed.status !== 'generated' || !patch}
-                                               onClick={() => applyPatch(
-                                                       rom,
-                                                       patch,
-                                                       `${i18n.t('alttpSeeds.filename', {
-                                                               hash: seed.hash,
-                                                               preset: seed.preset,
-                                                       })}.sfc`,
-                                               )}
-                                               variant="primary"
-                                       >
-                                               {i18n.t(patch ? 'alttpSeeds.patch' : 'alttpSeeds.fetchingPatch')}
-                                       </Button>
-                               :
-                                       <BaseRomButton />
-                               }
-                       </Col>
-                       <Col md={{ order: 1 }}>
-                               <p>
-                                       {i18n.t('alttpSeeds.preset')}:
-                                       {' '}
-                                       <strong>{i18n.t(`alttpSeeds.presets.${seed.preset}`)}</strong>
-                               </p>
-                               {seed.seed ?
-                                       <p>
-                                               {i18n.t('alttpSeeds.seed')}:
-                                               {' '}
-                                               <strong>{seed.seed}</strong>
-                                       </p>
-                               : null}
-                               {seed.race ?
-                                       <p>{i18n.t('alttpSeeds.race')}</p>
-                               : null}
-                               {seed.mystery ?
-                                       <p>{i18n.t('alttpSeeds.mystery')}</p>
-                               : null}
-                               {seed.status === 'generated' ?
-                                       <p>
-                                               {i18n.t('alttpSeeds.generated')}:
-                                               {' '}
-                                               <strong>
-                                                       {i18n.t('alttpSeeds.date', { date: new Date(seed.updated_at) })}
-                                               </strong>
-                                       </p>
-                               :
-                                       <p>
-                                               {i18n.t('alttpSeeds.status')}:
-                                               {' '}
-                                               <strong>{i18n.t(`alttpSeeds.statuses.${seed.status}`)}</strong>
-                                       </p>
-                               }
-                               {seed.status === 'error' ?
-                                       <p>
-                                               <Button
-                                                       onClick={onRetry}
-                                                       variant="secondary"
-                                               >
-                                                       {i18n.t('button.retry')}
-                                               </Button>
-                                       </p>
-                               : null}
-                       </Col>
-               </Row>
-               <h2 className="mt-5">{i18n.t('alttpSeeds.generator')}</h2>
-               <p>{i18n.t(`alttpSeeds.generators.${seed.generator}`)}</p>
-               {seed.settings ? <>
-                       <h2 className="mt-5">{i18n.t('alttpSeeds.settings')}</h2>
-                       <Row>
-                               {Object.entries(seed.settings).map(([key, value]) =>
-                                       <Col key={key} sm={4} md={3} lg={2} className="mb-2">
-                                               <small className="text-muted">
-                                                       {i18n.t(`alttpSeeds.settingName.${key}`)}
-                                               </small>
-                                               <br />
-                                               {isDefaultSetting(key, value) ?
-                                                       i18n.t(`alttpSeeds.settingValue.${key}.${value}`)
-                                               :
-                                                       <strong>{i18n.t(`alttpSeeds.settingValue.${key}.${value}`)}</strong>
-                                               }
-                                       </Col>
-                               )}
-                       </Row>
-               </> : null}
-       </Container>;
-};
-
-Seed.propTypes = {
-       onRetry: PropTypes.func,
-       patch: PropTypes.instanceOf(ArrayBuffer),
-       seed: PropTypes.shape({
-               generator: PropTypes.string,
-               hash: PropTypes.string,
-               mystery: PropTypes.bool,
-               preset: PropTypes.string,
-               race: PropTypes.bool,
-               seed: PropTypes.string,
-               settings: PropTypes.shape({
-               }),
-               status: PropTypes.string,
-               updated_at: PropTypes.string,
-       }),
-};
-
-export default withTranslation()(Seed);
diff --git a/resources/js/components/alttp-seeds/Seed.jsx b/resources/js/components/alttp-seeds/Seed.jsx
new file mode 100644 (file)
index 0000000..e804ae3
--- /dev/null
@@ -0,0 +1,139 @@
+import FileSaver from 'file-saver';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Container, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import BaseRomButton from './BaseRomButton';
+import { useAlttpBaseRom } from '../../helpers/AlttpBaseRomContext';
+import BPS from '../../helpers/bps';
+import i18n from '../../i18n';
+
+const applyPatch = (rom, patch, filename) => {
+       try {
+               const bps = new BPS();
+               bps.setPatch(patch);
+               bps.setSource(rom);
+               const result = bps.applyPatch();
+               FileSaver.saveAs(new Blob([result], { type: 'application/octet-stream' }), filename);
+       } catch (e) {
+               toastr.error(i18n.t('alttpSeeds.patchError', { msg: e.message }));
+       }
+};
+
+const isDefaultSetting = () => false;
+
+const Seed = ({ onRetry, patch, seed }) => {
+       const { rom } = useAlttpBaseRom();
+
+       return <Container>
+               <h1>{i18n.t('alttpSeeds.heading')}</h1>
+               <Row>
+                       <Col md={{ order: 2 }}>
+                               {rom ?
+                                       <Button
+                                               disabled={!seed || seed.status !== 'generated' || !patch}
+                                               onClick={() => applyPatch(
+                                                       rom,
+                                                       patch,
+                                                       `${i18n.t('alttpSeeds.filename', {
+                                                               hash: seed.hash,
+                                                               preset: seed.preset,
+                                                       })}.sfc`,
+                                               )}
+                                               variant="primary"
+                                       >
+                                               {i18n.t(patch ? 'alttpSeeds.patch' : 'alttpSeeds.fetchingPatch')}
+                                       </Button>
+                               :
+                                       <BaseRomButton />
+                               }
+                       </Col>
+                       <Col md={{ order: 1 }}>
+                               <p>
+                                       {i18n.t('alttpSeeds.preset')}:
+                                       {' '}
+                                       <strong>{i18n.t(`alttpSeeds.presets.${seed.preset}`)}</strong>
+                               </p>
+                               {seed.seed ?
+                                       <p>
+                                               {i18n.t('alttpSeeds.seed')}:
+                                               {' '}
+                                               <strong>{seed.seed}</strong>
+                                       </p>
+                               : null}
+                               {seed.race ?
+                                       <p>{i18n.t('alttpSeeds.race')}</p>
+                               : null}
+                               {seed.mystery ?
+                                       <p>{i18n.t('alttpSeeds.mystery')}</p>
+                               : null}
+                               {seed.status === 'generated' ?
+                                       <p>
+                                               {i18n.t('alttpSeeds.generated')}:
+                                               {' '}
+                                               <strong>
+                                                       {i18n.t('alttpSeeds.date', { date: new Date(seed.updated_at) })}
+                                               </strong>
+                                       </p>
+                               :
+                                       <p>
+                                               {i18n.t('alttpSeeds.status')}:
+                                               {' '}
+                                               <strong>{i18n.t(`alttpSeeds.statuses.${seed.status}`)}</strong>
+                                       </p>
+                               }
+                               {seed.status === 'error' ?
+                                       <p>
+                                               <Button
+                                                       onClick={onRetry}
+                                                       variant="secondary"
+                                               >
+                                                       {i18n.t('button.retry')}
+                                               </Button>
+                                       </p>
+                               : null}
+                       </Col>
+               </Row>
+               <h2 className="mt-5">{i18n.t('alttpSeeds.generator')}</h2>
+               <p>{i18n.t(`alttpSeeds.generators.${seed.generator}`)}</p>
+               {seed.settings ? <>
+                       <h2 className="mt-5">{i18n.t('alttpSeeds.settings')}</h2>
+                       <Row>
+                               {Object.entries(seed.settings).map(([key, value]) =>
+                                       <Col key={key} sm={4} md={3} lg={2} className="mb-2">
+                                               <small className="text-muted">
+                                                       {i18n.t(`alttpSeeds.settingName.${key}`)}
+                                               </small>
+                                               <br />
+                                               {isDefaultSetting(key, value) ?
+                                                       i18n.t(`alttpSeeds.settingValue.${key}.${value}`)
+                                               :
+                                                       <strong>{i18n.t(`alttpSeeds.settingValue.${key}.${value}`)}</strong>
+                                               }
+                                       </Col>
+                               )}
+                       </Row>
+               </> : null}
+       </Container>;
+};
+
+Seed.propTypes = {
+       onRetry: PropTypes.func,
+       patch: PropTypes.instanceOf(ArrayBuffer),
+       seed: PropTypes.shape({
+               generator: PropTypes.string,
+               hash: PropTypes.string,
+               mystery: PropTypes.bool,
+               preset: PropTypes.string,
+               race: PropTypes.bool,
+               seed: PropTypes.string,
+               settings: PropTypes.shape({
+               }),
+               status: PropTypes.string,
+               updated_at: PropTypes.string,
+       }),
+};
+
+export default withTranslation()(Seed);
diff --git a/resources/js/components/applications/Button.js b/resources/js/components/applications/Button.js
deleted file mode 100644 (file)
index 2361b09..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Badge, Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Dialog from './Dialog';
-import Icon from '../common/Icon';
-import { mayHandleApplications } from '../../helpers/permissions';
-import { getPendingApplications } from '../../helpers/Tournament';
-import { useUser } from '../../hooks/user';
-
-const ApplicationsButton = ({ tournament }) => {
-       const [showDialog, setShowDialog] = React.useState(false);
-
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       if (!user || !tournament.accept_applications || !mayHandleApplications(user, tournament)) {
-               return null;
-       }
-
-       const pending = getPendingApplications(tournament);
-
-       return <>
-               <Button
-                       onClick={() => setShowDialog(true)}
-                       title={t('tournaments.applications')}
-                       variant="primary"
-               >
-                       <Icon.APPLICATIONS title="" />
-                       {pending.length ?
-                               <>
-                                       {' '}
-                                       <Badge>{pending.length}</Badge>
-                               </>
-                       : null}
-               </Button>
-               <Dialog
-                       onHide={() => setShowDialog(false)}
-                       show={showDialog}
-                       tournament={tournament}
-               />
-       </>;
-};
-
-ApplicationsButton.propTypes = {
-       tournament: PropTypes.shape({
-               accept_applications: PropTypes.bool,
-               id: PropTypes.number,
-       }),
-};
-
-export default ApplicationsButton;
diff --git a/resources/js/components/applications/Button.jsx b/resources/js/components/applications/Button.jsx
new file mode 100644 (file)
index 0000000..2361b09
--- /dev/null
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Badge, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Dialog from './Dialog';
+import Icon from '../common/Icon';
+import { mayHandleApplications } from '../../helpers/permissions';
+import { getPendingApplications } from '../../helpers/Tournament';
+import { useUser } from '../../hooks/user';
+
+const ApplicationsButton = ({ tournament }) => {
+       const [showDialog, setShowDialog] = React.useState(false);
+
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       if (!user || !tournament.accept_applications || !mayHandleApplications(user, tournament)) {
+               return null;
+       }
+
+       const pending = getPendingApplications(tournament);
+
+       return <>
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       title={t('tournaments.applications')}
+                       variant="primary"
+               >
+                       <Icon.APPLICATIONS title="" />
+                       {pending.length ?
+                               <>
+                                       {' '}
+                                       <Badge>{pending.length}</Badge>
+                               </>
+                       : null}
+               </Button>
+               <Dialog
+                       onHide={() => setShowDialog(false)}
+                       show={showDialog}
+                       tournament={tournament}
+               />
+       </>;
+};
+
+ApplicationsButton.propTypes = {
+       tournament: PropTypes.shape({
+               accept_applications: PropTypes.bool,
+               id: PropTypes.number,
+       }),
+};
+
+export default ApplicationsButton;
diff --git a/resources/js/components/applications/Dialog.js b/resources/js/components/applications/Dialog.js
deleted file mode 100644 (file)
index a42fb3d..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button, Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import List from './List';
-import i18n from '../../i18n';
-
-const Dialog = ({ onHide, show, tournament }) =>
-<Modal className="applications-dialog" onHide={onHide} show={show}>
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t('tournaments.applications')}
-               </Modal.Title>
-       </Modal.Header>
-       <Modal.Body className="p-0">
-               {tournament.applications && tournament.applications.length ?
-                       <List tournament={tournament} />
-               :
-                       <Alert variant="info">
-                               {i18n.t('tournaments.noApplications')}
-                       </Alert>
-               }
-       </Modal.Body>
-       <Modal.Footer>
-               <Button onClick={onHide} variant="secondary">
-                       {i18n.t('button.close')}
-               </Button>
-       </Modal.Footer>
-</Modal>;
-
-Dialog.propTypes = {
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-               applications: PropTypes.arrayOf(PropTypes.shape({
-               }))
-       }),
-};
-
-export default withTranslation()(Dialog);
diff --git a/resources/js/components/applications/Dialog.jsx b/resources/js/components/applications/Dialog.jsx
new file mode 100644 (file)
index 0000000..a42fb3d
--- /dev/null
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import List from './List';
+import i18n from '../../i18n';
+
+const Dialog = ({ onHide, show, tournament }) =>
+<Modal className="applications-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('tournaments.applications')}
+               </Modal.Title>
+       </Modal.Header>
+       <Modal.Body className="p-0">
+               {tournament.applications && tournament.applications.length ?
+                       <List tournament={tournament} />
+               :
+                       <Alert variant="info">
+                               {i18n.t('tournaments.noApplications')}
+                       </Alert>
+               }
+       </Modal.Body>
+       <Modal.Footer>
+               <Button onClick={onHide} variant="secondary">
+                       {i18n.t('button.close')}
+               </Button>
+       </Modal.Footer>
+</Modal>;
+
+Dialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+               applications: PropTypes.arrayOf(PropTypes.shape({
+               }))
+       }),
+};
+
+export default withTranslation()(Dialog);
diff --git a/resources/js/components/applications/Item.js b/resources/js/components/applications/Item.js
deleted file mode 100644 (file)
index 8f6aef1..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, ListGroup } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import Icon from '../common/Icon';
-import Box from '../users/Box';
-import i18n from '../../i18n';
-
-const accept = async (tournament, application) => {
-       try {
-               await axios.post(`/api/application/${application.id}/accept`);
-               toastr.success(i18n.t('applications.acceptSuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('applications.acceptError'));
-       }
-};
-
-const reject = async (tournament, application) => {
-       try {
-               await axios.post(`/api/application/${application.id}/reject`);
-               toastr.success(i18n.t('applications.rejectSuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('applications.rejectError'));
-       }
-};
-
-const Item = ({ application, tournament }) =>
-<ListGroup.Item className="d-flex justify-content-between align-items-center">
-       <Box discriminator user={application.user} />
-       <div className="button-bar">
-               <Button
-                       onClick={() => accept(tournament, application)}
-                       title={i18n.t('applications.accept')}
-                       variant="success"
-               >
-                       <Icon.ACCEPT title="" />
-               </Button>
-               <Button
-                       onClick={() => reject(tournament, application)}
-                       title={i18n.t('applications.reject')}
-                       variant="danger"
-               >
-                       <Icon.REJECT title="" />
-               </Button>
-       </div>
-</ListGroup.Item>;
-
-Item.propTypes = {
-       application: PropTypes.shape({
-               user: PropTypes.shape({
-               }),
-       }),
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(Item);
diff --git a/resources/js/components/applications/Item.jsx b/resources/js/components/applications/Item.jsx
new file mode 100644 (file)
index 0000000..8f6aef1
--- /dev/null
@@ -0,0 +1,60 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, ListGroup } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import Icon from '../common/Icon';
+import Box from '../users/Box';
+import i18n from '../../i18n';
+
+const accept = async (tournament, application) => {
+       try {
+               await axios.post(`/api/application/${application.id}/accept`);
+               toastr.success(i18n.t('applications.acceptSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('applications.acceptError'));
+       }
+};
+
+const reject = async (tournament, application) => {
+       try {
+               await axios.post(`/api/application/${application.id}/reject`);
+               toastr.success(i18n.t('applications.rejectSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('applications.rejectError'));
+       }
+};
+
+const Item = ({ application, tournament }) =>
+<ListGroup.Item className="d-flex justify-content-between align-items-center">
+       <Box discriminator user={application.user} />
+       <div className="button-bar">
+               <Button
+                       onClick={() => accept(tournament, application)}
+                       title={i18n.t('applications.accept')}
+                       variant="success"
+               >
+                       <Icon.ACCEPT title="" />
+               </Button>
+               <Button
+                       onClick={() => reject(tournament, application)}
+                       title={i18n.t('applications.reject')}
+                       variant="danger"
+               >
+                       <Icon.REJECT title="" />
+               </Button>
+       </div>
+</ListGroup.Item>;
+
+Item.propTypes = {
+       application: PropTypes.shape({
+               user: PropTypes.shape({
+               }),
+       }),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(Item);
diff --git a/resources/js/components/applications/List.js b/resources/js/components/applications/List.js
deleted file mode 100644 (file)
index 6460be3..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { ListGroup } from 'react-bootstrap';
-
-import Item from './Item';
-
-const List = ({ tournament }) =>
-<ListGroup variant="flush">
-       {tournament.applications.map(application =>
-               <Item application={application} key={application.id} tournament={tournament} />
-       )}
-</ListGroup>;
-
-List.propTypes = {
-       tournament: PropTypes.shape({
-               applications: PropTypes.arrayOf(PropTypes.shape({
-               })),
-       }),
-};
-
-export default List;
diff --git a/resources/js/components/applications/List.jsx b/resources/js/components/applications/List.jsx
new file mode 100644 (file)
index 0000000..6460be3
--- /dev/null
@@ -0,0 +1,21 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ListGroup } from 'react-bootstrap';
+
+import Item from './Item';
+
+const List = ({ tournament }) =>
+<ListGroup variant="flush">
+       {tournament.applications.map(application =>
+               <Item application={application} key={application.id} tournament={tournament} />
+       )}
+</ListGroup>;
+
+List.propTypes = {
+       tournament: PropTypes.shape({
+               applications: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+};
+
+export default List;
diff --git a/resources/js/components/channel/Item.js b/resources/js/components/channel/Item.js
deleted file mode 100644 (file)
index a2b712f..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Icon from '../common/Icon';
-
-const Item = ({ channel }) => {
-       const classNames = [
-               'channel-item',
-               channel.twitch_live ? 'is-live' : 'not-live',
-       ];
-       return <a
-               className={classNames.join(' ')}
-               href={channel.stream_link}
-               rel="noreferrer"
-               target="_blank"
-       >
-               <div className="d-flex justify-content-between">
-                       <div className="channel-title fs-5">{channel.title}</div>
-                       {channel.twitch_live ?
-                               <div className="channel-viewers">
-                                       <Icon.STREAM />
-                                       {' '}
-                                       {channel.twitch_viewers}
-                               </div>
-                       : null}
-               </div>
-               <div className="channel-stream-title">{channel.twitch_title}</div>
-               <div className="channel-category text-muted">
-                       <small>{channel.twitch_category_name}</small>
-               </div>
-       </a>;
-};
-
-Item.propTypes = {
-       channel: PropTypes.shape({
-               stream_link: PropTypes.string,
-               title: PropTypes.string,
-               twitch_category_name: PropTypes.string,
-               twitch_live: PropTypes.bool,
-               twitch_title: PropTypes.string,
-               twitch_viewers: PropTypes.number,
-       }),
-};
-
-export default Item;
diff --git a/resources/js/components/channel/Item.jsx b/resources/js/components/channel/Item.jsx
new file mode 100644 (file)
index 0000000..a2b712f
--- /dev/null
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Icon from '../common/Icon';
+
+const Item = ({ channel }) => {
+       const classNames = [
+               'channel-item',
+               channel.twitch_live ? 'is-live' : 'not-live',
+       ];
+       return <a
+               className={classNames.join(' ')}
+               href={channel.stream_link}
+               rel="noreferrer"
+               target="_blank"
+       >
+               <div className="d-flex justify-content-between">
+                       <div className="channel-title fs-5">{channel.title}</div>
+                       {channel.twitch_live ?
+                               <div className="channel-viewers">
+                                       <Icon.STREAM />
+                                       {' '}
+                                       {channel.twitch_viewers}
+                               </div>
+                       : null}
+               </div>
+               <div className="channel-stream-title">{channel.twitch_title}</div>
+               <div className="channel-category text-muted">
+                       <small>{channel.twitch_category_name}</small>
+               </div>
+       </a>;
+};
+
+Item.propTypes = {
+       channel: PropTypes.shape({
+               stream_link: PropTypes.string,
+               title: PropTypes.string,
+               twitch_category_name: PropTypes.string,
+               twitch_live: PropTypes.bool,
+               twitch_title: PropTypes.string,
+               twitch_viewers: PropTypes.number,
+       }),
+};
+
+export default Item;
diff --git a/resources/js/components/channel/Link.js b/resources/js/components/channel/Link.js
deleted file mode 100644 (file)
index 3eeb7b9..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-
-import Icon from '../common/Icon';
-
-const Link = ({ channel }) => {
-       return <Button
-               href={channel.stream_link}
-               rel="noreferrer"
-               target="_blank"
-               title={channel.title}
-               variant="outline-twitch"
-       >
-               <Icon.STREAM />
-               &nbsp;
-               {channel.short_name || channel.title}
-       </Button>;
-};
-
-Link.propTypes = {
-       channel: PropTypes.shape({
-               short_name: PropTypes.string,
-               stream_link: PropTypes.string,
-               title: PropTypes.string,
-       }),
-};
-
-export default Link;
diff --git a/resources/js/components/channel/Link.jsx b/resources/js/components/channel/Link.jsx
new file mode 100644 (file)
index 0000000..3eeb7b9
--- /dev/null
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import Icon from '../common/Icon';
+
+const Link = ({ channel }) => {
+       return <Button
+               href={channel.stream_link}
+               rel="noreferrer"
+               target="_blank"
+               title={channel.title}
+               variant="outline-twitch"
+       >
+               <Icon.STREAM />
+               &nbsp;
+               {channel.short_name || channel.title}
+       </Button>;
+};
+
+Link.propTypes = {
+       channel: PropTypes.shape({
+               short_name: PropTypes.string,
+               stream_link: PropTypes.string,
+               title: PropTypes.string,
+       }),
+};
+
+export default Link;
diff --git a/resources/js/components/channel/List.js b/resources/js/components/channel/List.js
deleted file mode 100644 (file)
index c1c4d41..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Item from './Item';
-
-const List = ({ channels = [] }) => {
-       return <div className="channel-list">
-               {channels.map(channel =>
-                       <Item channel={channel} key={channel.id} />
-               )}
-       </div>;
-};
-
-List.propTypes = {
-       channels: PropTypes.arrayOf(PropTypes.shape({
-       })),
-};
-
-export default List;
diff --git a/resources/js/components/channel/List.jsx b/resources/js/components/channel/List.jsx
new file mode 100644 (file)
index 0000000..c1c4d41
--- /dev/null
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Item from './Item';
+
+const List = ({ channels = [] }) => {
+       return <div className="channel-list">
+               {channels.map(channel =>
+                       <Item channel={channel} key={channel.id} />
+               )}
+       </div>;
+};
+
+List.propTypes = {
+       channels: PropTypes.arrayOf(PropTypes.shape({
+       })),
+};
+
+export default List;
diff --git a/resources/js/components/chat-bot-logs/ChatBotLog.js b/resources/js/components/chat-bot-logs/ChatBotLog.js
deleted file mode 100644 (file)
index 45c8027..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useEffect, useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Dialog from './Dialog';
-import Icon from '../common/Icon';
-
-const ChatBotLog = ({ id }) => {
-       const [showDialog, setShowDialog] = useState(false);
-       const [log, setLog] = useState([]);
-
-       const { t } = useTranslation();
-
-       useEffect(() => {
-               if (!showDialog) return;
-               const ctrl = new AbortController();
-               axios
-                       .get(`/api/channels/${id}/chat-bot-log`, { signal: ctrl.signal })
-                       .then(response => {
-                               setLog(response.data);
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [id, showDialog]);
-
-       return (
-               <>
-                       <Button
-                               onClick={() => setShowDialog(true)}
-                               title={t('button.protocol')}
-                               variant="outline-info"
-                       >
-                               <Icon.PROTOCOL title="" />
-                       </Button>
-                       <Dialog
-                               log={log}
-                               onHide={() => setShowDialog(false)}
-                               show={showDialog}
-                       />
-               </>
-       );
-};
-
-ChatBotLog.propTypes = {
-       id: PropTypes.number,
-};
-
-export default ChatBotLog;
diff --git a/resources/js/components/chat-bot-logs/ChatBotLog.jsx b/resources/js/components/chat-bot-logs/ChatBotLog.jsx
new file mode 100644 (file)
index 0000000..45c8027
--- /dev/null
@@ -0,0 +1,51 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useEffect, useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Dialog from './Dialog';
+import Icon from '../common/Icon';
+
+const ChatBotLog = ({ id }) => {
+       const [showDialog, setShowDialog] = useState(false);
+       const [log, setLog] = useState([]);
+
+       const { t } = useTranslation();
+
+       useEffect(() => {
+               if (!showDialog) return;
+               const ctrl = new AbortController();
+               axios
+                       .get(`/api/channels/${id}/chat-bot-log`, { signal: ctrl.signal })
+                       .then(response => {
+                               setLog(response.data);
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [id, showDialog]);
+
+       return (
+               <>
+                       <Button
+                               onClick={() => setShowDialog(true)}
+                               title={t('button.protocol')}
+                               variant="outline-info"
+                       >
+                               <Icon.PROTOCOL title="" />
+                       </Button>
+                       <Dialog
+                               log={log}
+                               onHide={() => setShowDialog(false)}
+                               show={showDialog}
+                       />
+               </>
+       );
+};
+
+ChatBotLog.propTypes = {
+       id: PropTypes.number,
+};
+
+export default ChatBotLog;
diff --git a/resources/js/components/chat-bot-logs/Dialog.js b/resources/js/components/chat-bot-logs/Dialog.js
deleted file mode 100644 (file)
index ffe457c..0000000
+++ /dev/null
@@ -1,65 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button, Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import List from './List';
-import i18n from '../../i18n';
-
-class Dialog extends React.Component {
-
-       componentDidMount() {
-               this.timer = setInterval(() => {
-                       this.forceUpdate();
-               }, 30000);
-       }
-
-       componentWillUnmount() {
-               clearInterval(this.timer);
-       }
-
-       render() {
-               const {
-                       log,
-                       onHide,
-                       show,
-               } = this.props;
-               return <Modal className="chat-bot-log-dialog" onHide={onHide} show={show} size="lg">
-                       <Modal.Header closeButton>
-                               <Modal.Title>
-                                       {i18n.t('chatBotLog.heading')}
-                               </Modal.Title>
-                       </Modal.Header>
-                       {log && log.length ?
-                               <List log={log} />
-                       :
-                               <Modal.Body>
-                                       <Alert variant="info">
-                                               {i18n.t('chatBotLog.empty')}
-                                       </Alert>
-                               </Modal.Body>
-                       }
-                       <Modal.Footer>
-                               <Button onClick={onHide} variant="secondary">
-                                       {i18n.t('button.close')}
-                               </Button>
-                       </Modal.Footer>
-               </Modal>;
-       }
-
-}
-
-Dialog.propTypes = {
-       log: PropTypes.arrayOf(PropTypes.shape({
-       })),
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-};
-
-Dialog.defaultProps = {
-       log: null,
-       onHide: null,
-       show: false,
-};
-
-export default withTranslation()(Dialog);
diff --git a/resources/js/components/chat-bot-logs/Dialog.jsx b/resources/js/components/chat-bot-logs/Dialog.jsx
new file mode 100644 (file)
index 0000000..ffe457c
--- /dev/null
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import List from './List';
+import i18n from '../../i18n';
+
+class Dialog extends React.Component {
+
+       componentDidMount() {
+               this.timer = setInterval(() => {
+                       this.forceUpdate();
+               }, 30000);
+       }
+
+       componentWillUnmount() {
+               clearInterval(this.timer);
+       }
+
+       render() {
+               const {
+                       log,
+                       onHide,
+                       show,
+               } = this.props;
+               return <Modal className="chat-bot-log-dialog" onHide={onHide} show={show} size="lg">
+                       <Modal.Header closeButton>
+                               <Modal.Title>
+                                       {i18n.t('chatBotLog.heading')}
+                               </Modal.Title>
+                       </Modal.Header>
+                       {log && log.length ?
+                               <List log={log} />
+                       :
+                               <Modal.Body>
+                                       <Alert variant="info">
+                                               {i18n.t('chatBotLog.empty')}
+                                       </Alert>
+                               </Modal.Body>
+                       }
+                       <Modal.Footer>
+                               <Button onClick={onHide} variant="secondary">
+                                       {i18n.t('button.close')}
+                               </Button>
+                       </Modal.Footer>
+               </Modal>;
+       }
+
+}
+
+Dialog.propTypes = {
+       log: PropTypes.arrayOf(PropTypes.shape({
+       })),
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+};
+
+Dialog.defaultProps = {
+       log: null,
+       onHide: null,
+       show: false,
+};
+
+export default withTranslation()(Dialog);
diff --git a/resources/js/components/chat-bot-logs/Item.js b/resources/js/components/chat-bot-logs/Item.js
deleted file mode 100644 (file)
index 395ad33..0000000
+++ /dev/null
@@ -1,143 +0,0 @@
-import axios from 'axios';
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, ListGroup, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import ChannelLink from '../channel/Link';
-import List from '../chat-logs/List';
-import Icon from '../common/Icon';
-import Loading from '../common/Loading';
-import { getUserName } from '../../helpers/User';
-
-const getEntryDate = entry => moment(entry.created_at).fromNow();
-
-const getEntryOrigin = (entry, t) => {
-       return t('chatBotLog.origin.chatLog', {
-               channel: entry.origin.params[0],
-               date: new Date(entry.origin.created_at),
-               nick: entry.origin.nick,
-       });
-};
-
-const getEntryInfo = (entry, t) => {
-       if (entry.user && entry.category) {
-               return t('chatBotLog.info.userCat', {
-                       category: t(`twitchBot.chatCategories.${entry.category}`),
-                       date: getEntryDate(entry),
-                       user: getUserName(entry.user),
-               });
-       }
-       if (entry.category) {
-               return t('chatBotLog.info.cat', {
-                       category: t(`twitchBot.chatCategories.${entry.category}`),
-                       date: getEntryDate(entry),
-               });
-       }
-       if (entry.user) {
-               return t('chatBotLog.info.user', {
-                       date: getEntryDate(entry),
-                       user: getUserName(entry.user),
-               });
-       }
-       return getEntryDate(entry);
-};
-
-const Item = ({ entry = {} }) => {
-       const [context, setContext] = React.useState(null);
-       const [contextLoading, setContextLoading] = React.useState(true);
-       const [showContext, setShowContext] = React.useState(false);
-
-       const { t } = useTranslation();
-
-       React.useEffect(() => {
-               if (context || !showContext) return;
-               const ctrl = new AbortController();
-               axios
-                       .get(`/api/chatbotlogs/${entry.id}/context`, {
-                               signal: ctrl.signal
-                       })
-                       .then(response => {
-                               setContextLoading(false);
-                               setContext(response.data);
-                       })
-                       .catch(error => {
-                               if (!axios.isCancel(error)) {
-                                       setContextLoading(false);
-                                       setContext(null);
-                               }
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [context, showContext]);
-
-       return <ListGroup.Item>
-               <div className="d-flex justify-content-between">
-                       <div>
-                               <div>
-                                       {entry.text}
-                               </div>
-                               {entry.origin ?
-                                       <div
-                                               className="text-muted"
-                                       >
-                                               {getEntryOrigin(entry, t)}
-                                       </div>
-                               : null}
-                               <div
-                                       className="text-muted"
-                                       title={moment(entry.created_at).format('LLLL')}
-                               >
-                                       {getEntryInfo(entry, t)}
-                               </div>
-                       </div>
-                       <div>
-                               {entry.channel ?
-                                       <ChannelLink channel={entry.channel} />
-                               : null}
-                               <Button
-                                       className="ms-2"
-                                       onClick={() => { setShowContext(c => !c); }}
-                                       title={t('chatBotLog.showContext')}
-                                       variant={showContext ? 'secondary' : 'outline-secondary'}
-                               >
-                                       <Icon.PROTOCOL title="" />
-                               </Button>
-                       </div>
-               </div>
-               {showContext ?
-                       <div className="chat-bot-log-context mt-2">
-                               {contextLoading ?
-                                       <Loading />
-                               : null}
-                               {context ?
-                                       <Row>
-                                               <Col sm={6}>
-                                                       <h3 className="fs-6">{t('chatBotLog.context')}</h3>
-                                                       <List log={context.current} />
-                                               </Col>
-                                               {context.original ?
-                                                       <Col sm={6}>
-                                                               <h3 className="fs-6">{t('chatBotLog.originalContext')}</h3>
-                                                               <List log={context.original} />
-                                                       </Col>
-                                               : null}
-                                       </Row>
-                               : null}
-                       </div>
-               : null}
-       </ListGroup.Item>;
-};
-
-Item.propTypes = {
-       entry: PropTypes.shape({
-               channel: PropTypes.shape({}),
-               created_at: PropTypes.string,
-               origin: PropTypes.shape({}),
-               text: PropTypes.string,
-       }),
-};
-
-export default Item;
diff --git a/resources/js/components/chat-bot-logs/Item.jsx b/resources/js/components/chat-bot-logs/Item.jsx
new file mode 100644 (file)
index 0000000..395ad33
--- /dev/null
@@ -0,0 +1,143 @@
+import axios from 'axios';
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, ListGroup, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import ChannelLink from '../channel/Link';
+import List from '../chat-logs/List';
+import Icon from '../common/Icon';
+import Loading from '../common/Loading';
+import { getUserName } from '../../helpers/User';
+
+const getEntryDate = entry => moment(entry.created_at).fromNow();
+
+const getEntryOrigin = (entry, t) => {
+       return t('chatBotLog.origin.chatLog', {
+               channel: entry.origin.params[0],
+               date: new Date(entry.origin.created_at),
+               nick: entry.origin.nick,
+       });
+};
+
+const getEntryInfo = (entry, t) => {
+       if (entry.user && entry.category) {
+               return t('chatBotLog.info.userCat', {
+                       category: t(`twitchBot.chatCategories.${entry.category}`),
+                       date: getEntryDate(entry),
+                       user: getUserName(entry.user),
+               });
+       }
+       if (entry.category) {
+               return t('chatBotLog.info.cat', {
+                       category: t(`twitchBot.chatCategories.${entry.category}`),
+                       date: getEntryDate(entry),
+               });
+       }
+       if (entry.user) {
+               return t('chatBotLog.info.user', {
+                       date: getEntryDate(entry),
+                       user: getUserName(entry.user),
+               });
+       }
+       return getEntryDate(entry);
+};
+
+const Item = ({ entry = {} }) => {
+       const [context, setContext] = React.useState(null);
+       const [contextLoading, setContextLoading] = React.useState(true);
+       const [showContext, setShowContext] = React.useState(false);
+
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               if (context || !showContext) return;
+               const ctrl = new AbortController();
+               axios
+                       .get(`/api/chatbotlogs/${entry.id}/context`, {
+                               signal: ctrl.signal
+                       })
+                       .then(response => {
+                               setContextLoading(false);
+                               setContext(response.data);
+                       })
+                       .catch(error => {
+                               if (!axios.isCancel(error)) {
+                                       setContextLoading(false);
+                                       setContext(null);
+                               }
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [context, showContext]);
+
+       return <ListGroup.Item>
+               <div className="d-flex justify-content-between">
+                       <div>
+                               <div>
+                                       {entry.text}
+                               </div>
+                               {entry.origin ?
+                                       <div
+                                               className="text-muted"
+                                       >
+                                               {getEntryOrigin(entry, t)}
+                                       </div>
+                               : null}
+                               <div
+                                       className="text-muted"
+                                       title={moment(entry.created_at).format('LLLL')}
+                               >
+                                       {getEntryInfo(entry, t)}
+                               </div>
+                       </div>
+                       <div>
+                               {entry.channel ?
+                                       <ChannelLink channel={entry.channel} />
+                               : null}
+                               <Button
+                                       className="ms-2"
+                                       onClick={() => { setShowContext(c => !c); }}
+                                       title={t('chatBotLog.showContext')}
+                                       variant={showContext ? 'secondary' : 'outline-secondary'}
+                               >
+                                       <Icon.PROTOCOL title="" />
+                               </Button>
+                       </div>
+               </div>
+               {showContext ?
+                       <div className="chat-bot-log-context mt-2">
+                               {contextLoading ?
+                                       <Loading />
+                               : null}
+                               {context ?
+                                       <Row>
+                                               <Col sm={6}>
+                                                       <h3 className="fs-6">{t('chatBotLog.context')}</h3>
+                                                       <List log={context.current} />
+                                               </Col>
+                                               {context.original ?
+                                                       <Col sm={6}>
+                                                               <h3 className="fs-6">{t('chatBotLog.originalContext')}</h3>
+                                                               <List log={context.original} />
+                                                       </Col>
+                                               : null}
+                                       </Row>
+                               : null}
+                       </div>
+               : null}
+       </ListGroup.Item>;
+};
+
+Item.propTypes = {
+       entry: PropTypes.shape({
+               channel: PropTypes.shape({}),
+               created_at: PropTypes.string,
+               origin: PropTypes.shape({}),
+               text: PropTypes.string,
+       }),
+};
+
+export default Item;
diff --git a/resources/js/components/chat-bot-logs/List.js b/resources/js/components/chat-bot-logs/List.js
deleted file mode 100644 (file)
index 52a4de8..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { ListGroup } from 'react-bootstrap';
-
-import Item from './Item';
-
-class List extends React.Component {
-
-       componentDidMount() {
-               this.timer = setInterval(() => {
-                       this.forceUpdate();
-               }, 30000);
-       }
-
-       componentWillUnmount() {
-               clearInterval(this.timer);
-       }
-
-       render() {
-               const { log } = this.props;
-
-               return <ListGroup variant="flush">
-                       {log ? log.map(entry =>
-                               <Item key={entry.id} entry={entry} />
-                       ) : null}
-               </ListGroup>;
-       }
-
-}
-
-List.propTypes = {
-       log: PropTypes.arrayOf(PropTypes.shape({
-       })),
-};
-
-List.defaultProps = {
-       log: [],
-};
-
-export default List;
diff --git a/resources/js/components/chat-bot-logs/List.jsx b/resources/js/components/chat-bot-logs/List.jsx
new file mode 100644 (file)
index 0000000..52a4de8
--- /dev/null
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ListGroup } from 'react-bootstrap';
+
+import Item from './Item';
+
+class List extends React.Component {
+
+       componentDidMount() {
+               this.timer = setInterval(() => {
+                       this.forceUpdate();
+               }, 30000);
+       }
+
+       componentWillUnmount() {
+               clearInterval(this.timer);
+       }
+
+       render() {
+               const { log } = this.props;
+
+               return <ListGroup variant="flush">
+                       {log ? log.map(entry =>
+                               <Item key={entry.id} entry={entry} />
+                       ) : null}
+               </ListGroup>;
+       }
+
+}
+
+List.propTypes = {
+       log: PropTypes.arrayOf(PropTypes.shape({
+       })),
+};
+
+List.defaultProps = {
+       log: [],
+};
+
+export default List;
diff --git a/resources/js/components/chat-logs/Item.js b/resources/js/components/chat-logs/Item.js
deleted file mode 100644 (file)
index 52110e3..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-const getChatterColor = entry => {
-       if (entry.tags && entry.tags['color']) {
-               return entry.tags['color'];
-       }
-       return 'inherit';
-};
-
-const getChatterNick = entry => {
-       if (entry.tags && entry.tags['display-name']) {
-               return entry.tags['display-name'];
-       }
-       return entry.nick;
-};
-
-const getTextContent = entry => {
-       if (entry.params && entry.params.length >= 2) {
-               return entry.params[1];
-       }
-       return entry.text_content;
-};
-
-const getTimestamp = entry => {
-       if (entry.tags && entry.tags['tmi-sent-ts']) {
-               return new Date(parseInt(entry.tags['tmi-sent-ts'], 10));
-       }
-       return new Date(entry.created_at);
-};
-
-const Item = ({ entry }) => {
-       const { t } = useTranslation();
-
-       return <div className="chat-log-item">
-               <div>
-                       <span className="text-muted me-2">
-                               {t('chatBotLog.shortTimestamp', { date: getTimestamp(entry) })}
-                       </span>
-                       <strong style={{ color: getChatterColor(entry) }}>{getChatterNick(entry)}</strong>
-               </div>
-               <div>{getTextContent(entry)}</div>
-       </div>;
-};
-
-Item.propTypes = {
-       entry: PropTypes.shape({
-               text_content: PropTypes.string,
-       }).isRequired,
-};
-
-export default Item;
diff --git a/resources/js/components/chat-logs/Item.jsx b/resources/js/components/chat-logs/Item.jsx
new file mode 100644 (file)
index 0000000..52110e3
--- /dev/null
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+const getChatterColor = entry => {
+       if (entry.tags && entry.tags['color']) {
+               return entry.tags['color'];
+       }
+       return 'inherit';
+};
+
+const getChatterNick = entry => {
+       if (entry.tags && entry.tags['display-name']) {
+               return entry.tags['display-name'];
+       }
+       return entry.nick;
+};
+
+const getTextContent = entry => {
+       if (entry.params && entry.params.length >= 2) {
+               return entry.params[1];
+       }
+       return entry.text_content;
+};
+
+const getTimestamp = entry => {
+       if (entry.tags && entry.tags['tmi-sent-ts']) {
+               return new Date(parseInt(entry.tags['tmi-sent-ts'], 10));
+       }
+       return new Date(entry.created_at);
+};
+
+const Item = ({ entry }) => {
+       const { t } = useTranslation();
+
+       return <div className="chat-log-item">
+               <div>
+                       <span className="text-muted me-2">
+                               {t('chatBotLog.shortTimestamp', { date: getTimestamp(entry) })}
+                       </span>
+                       <strong style={{ color: getChatterColor(entry) }}>{getChatterNick(entry)}</strong>
+               </div>
+               <div>{getTextContent(entry)}</div>
+       </div>;
+};
+
+Item.propTypes = {
+       entry: PropTypes.shape({
+               text_content: PropTypes.string,
+       }).isRequired,
+};
+
+export default Item;
diff --git a/resources/js/components/chat-logs/List.js b/resources/js/components/chat-logs/List.js
deleted file mode 100644 (file)
index 8da17e2..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Item from './Item';
-
-const List = ({ log = [] }) => {
-       return <div className="chat-log-list">
-               {log.map(entry =>
-                       <Item key={entry.id} entry={entry} />
-               )}
-       </div>;
-};
-
-List.propTypes = {
-       log: PropTypes.arrayOf(PropTypes.shape({
-       })),
-};
-
-export default List;
diff --git a/resources/js/components/chat-logs/List.jsx b/resources/js/components/chat-logs/List.jsx
new file mode 100644 (file)
index 0000000..8da17e2
--- /dev/null
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Item from './Item';
+
+const List = ({ log = [] }) => {
+       return <div className="chat-log-list">
+               {log.map(entry =>
+                       <Item key={entry.id} entry={entry} />
+               )}
+       </div>;
+};
+
+List.propTypes = {
+       log: PropTypes.arrayOf(PropTypes.shape({
+       })),
+};
+
+export default List;
diff --git a/resources/js/components/common/AspectBox.js b/resources/js/components/common/AspectBox.js
deleted file mode 100644 (file)
index ead009d..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-const AspectBox = ({ children = null, ratio = 1 }) =>
-       <div className="aspect-box-container" style={{ paddingTop: `${1 / ratio * 100}%`}}>
-               <div className="aspect-box-content">
-                       {children}
-               </div>
-       </div>;
-
-AspectBox.propTypes = {
-       children: PropTypes.node,
-       ratio: PropTypes.number,
-};
-
-export default AspectBox;
diff --git a/resources/js/components/common/AspectBox.jsx b/resources/js/components/common/AspectBox.jsx
new file mode 100644 (file)
index 0000000..ead009d
--- /dev/null
@@ -0,0 +1,16 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const AspectBox = ({ children = null, ratio = 1 }) =>
+       <div className="aspect-box-container" style={{ paddingTop: `${1 / ratio * 100}%`}}>
+               <div className="aspect-box-content">
+                       {children}
+               </div>
+       </div>;
+
+AspectBox.propTypes = {
+       children: PropTypes.node,
+       ratio: PropTypes.number,
+};
+
+export default AspectBox;
diff --git a/resources/js/components/common/CanonicalLinks.js b/resources/js/components/common/CanonicalLinks.js
deleted file mode 100644 (file)
index 26e8696..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-
-const CanonicalLinks = ({ base, lang, langs }) => {
-       const { i18n } = useTranslation();
-
-       const activeLang = lang || i18n.language;
-       const availableLangs = langs || ['de', 'en'];
-
-       return <Helmet>
-               <link
-                       href={`https://alttp.localhorst.tv${base}?lng=${activeLang}`}
-                       hrefLang={activeLang}
-                       rel="canonical"
-               />
-               <link
-                       href={`https://alttp.localhorst.tv${base}`}
-                       hrefLang="x-default"
-                       rel="alternate"
-               />
-               {availableLangs.filter(l => l !== activeLang).map(l =>
-                       <link
-                               key={l}
-                               href={`https://alttp.localhorst.tv${base}?lng=${l}`}
-                               hrefLang={l}
-                               rel="alternate"
-                       />
-               )}
-       </Helmet>;
-};
-
-CanonicalLinks.propTypes = {
-       base: PropTypes.string.isRequired,
-       lang: PropTypes.string,
-       langs: PropTypes.arrayOf(PropTypes.string),
-};
-
-export default CanonicalLinks;
diff --git a/resources/js/components/common/CanonicalLinks.jsx b/resources/js/components/common/CanonicalLinks.jsx
new file mode 100644 (file)
index 0000000..26e8696
--- /dev/null
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+
+const CanonicalLinks = ({ base, lang, langs }) => {
+       const { i18n } = useTranslation();
+
+       const activeLang = lang || i18n.language;
+       const availableLangs = langs || ['de', 'en'];
+
+       return <Helmet>
+               <link
+                       href={`https://alttp.localhorst.tv${base}?lng=${activeLang}`}
+                       hrefLang={activeLang}
+                       rel="canonical"
+               />
+               <link
+                       href={`https://alttp.localhorst.tv${base}`}
+                       hrefLang="x-default"
+                       rel="alternate"
+               />
+               {availableLangs.filter(l => l !== activeLang).map(l =>
+                       <link
+                               key={l}
+                               href={`https://alttp.localhorst.tv${base}?lng=${l}`}
+                               hrefLang={l}
+                               rel="alternate"
+                       />
+               )}
+       </Helmet>;
+};
+
+CanonicalLinks.propTypes = {
+       base: PropTypes.string.isRequired,
+       lang: PropTypes.string,
+       langs: PropTypes.arrayOf(PropTypes.string),
+};
+
+export default CanonicalLinks;
diff --git a/resources/js/components/common/ChannelSelect.js b/resources/js/components/common/ChannelSelect.js
deleted file mode 100644 (file)
index 61b34e9..0000000
+++ /dev/null
@@ -1,148 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { Alert, Button, Form, ListGroup } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from './Icon';
-import debounce from '../../helpers/debounce';
-
-const ChannelSelect = ({
-       autoSelect,
-       joinable,
-       manageable,
-       onChange,
-       readOnly,
-       value,
-}) => {
-       const [resolved, setResolved] = useState(null);
-       const [results, setResults] = useState([]);
-       const [search, setSearch] = useState('');
-       const [showResults, setShowResults] = useState(false);
-
-       const ref = useRef(null);
-       const { t } = useTranslation();
-
-       useEffect(() => {
-               const handleEventOutside = e => {
-                       if (ref.current && !ref.current.contains(e.target)) {
-                               setShowResults(false);
-                       }
-               };
-               document.addEventListener('mousedown', handleEventOutside, true);
-               document.addEventListener('focus', handleEventOutside, true);
-               return () => {
-                       document.removeEventListener('mousedown', handleEventOutside, true);
-                       document.removeEventListener('focus', handleEventOutside, true);
-               };
-       }, []);
-
-       let ctrl = null;
-       const fetch = useCallback(debounce(async phrase => {
-               if (ctrl) {
-                       ctrl.abort();
-               }
-               ctrl = new AbortController();
-               try {
-                       const response = await axios.get(`/api/channels`, {
-                               params: {
-                                       joinable: joinable ? 1 : 0,
-                                       limit: 5,
-                                       manageable: manageable ? 1 : 0,
-                                       phrase,
-                               },
-                               signal: ctrl.signal,
-                       });
-                       ctrl = null;
-                       setResults(response.data);
-                       if (autoSelect && !phrase && response.data.length === 1) {
-                               onChange({
-                                       channel: response.data[0],
-                                       target: { value: response.data[0].id },
-                               });
-                       }
-               } catch (e) {
-                       ctrl = null;
-                       console.error(e);
-               }
-       }, 300), [autoSelect, joinable, manageable]);
-
-       useEffect(() => {
-               fetch(search);
-       }, [search]);
-
-       useEffect(() => {
-               if (value) {
-                       axios
-                               .get(`/api/channels/${value}`)
-                       .then(response => {
-                               setResolved(response.data);
-                       });
-               } else {
-                       setResolved(null);
-               }
-       }, [value]);
-
-       if (value) {
-               return <div className="d-flex align-items-center justify-content-between">
-                       <span>{resolved ? resolved.title : value}</span>
-                       {!readOnly ?
-                               <Button
-                                       className="ms-2"
-                                       onClick={() => onChange({ channel: null, target: { value: '' }})}
-                                       title={t('button.unset')}
-                                       variant="outline-danger"
-                               >
-                                       <Icon.REMOVE title="" />
-                               </Button>
-                       : null}
-               </div>;
-       }
-       return <div className={`channel-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
-               <Form.Control
-                       className="search-input"
-                       name={Math.random().toString(20).substr(2, 10)}
-                       onChange={e => setSearch(e.target.value)}
-                       onFocus={() => setShowResults(true)}
-                       readOnly={readOnly}
-                       type="search"
-                       value={search}
-               />
-               <div className="search-results-holder">
-                       {results.length ?
-                               <ListGroup className="search-results">
-                                       {results.map(result =>
-                                               <ListGroup.Item
-                                                       action
-                                                       key={result.id}
-                                                       onClick={() => onChange({
-                                                               channel: result,
-                                                               target: { value: result.id },
-                                                       })}
-                                               >
-                                                       {result.title}
-                                               </ListGroup.Item>
-                                       )}
-                               </ListGroup>
-                       :
-                               <Alert className="search-results" variant="info">
-                                       {t('search.noResults')}
-                               </Alert>
-                       }
-               </div>
-       </div>;
-};
-
-ChannelSelect.propTypes = {
-       autoSelect: PropTypes.bool,
-       joinable: PropTypes.bool,
-       manageable: PropTypes.bool,
-       onChange: PropTypes.func,
-       readOnly: PropTypes.bool,
-       value: PropTypes.oneOfType([
-               PropTypes.number,
-               PropTypes.string,
-       ]),
-};
-
-export default ChannelSelect;
diff --git a/resources/js/components/common/ChannelSelect.jsx b/resources/js/components/common/ChannelSelect.jsx
new file mode 100644 (file)
index 0000000..61b34e9
--- /dev/null
@@ -0,0 +1,148 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Alert, Button, Form, ListGroup } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from './Icon';
+import debounce from '../../helpers/debounce';
+
+const ChannelSelect = ({
+       autoSelect,
+       joinable,
+       manageable,
+       onChange,
+       readOnly,
+       value,
+}) => {
+       const [resolved, setResolved] = useState(null);
+       const [results, setResults] = useState([]);
+       const [search, setSearch] = useState('');
+       const [showResults, setShowResults] = useState(false);
+
+       const ref = useRef(null);
+       const { t } = useTranslation();
+
+       useEffect(() => {
+               const handleEventOutside = e => {
+                       if (ref.current && !ref.current.contains(e.target)) {
+                               setShowResults(false);
+                       }
+               };
+               document.addEventListener('mousedown', handleEventOutside, true);
+               document.addEventListener('focus', handleEventOutside, true);
+               return () => {
+                       document.removeEventListener('mousedown', handleEventOutside, true);
+                       document.removeEventListener('focus', handleEventOutside, true);
+               };
+       }, []);
+
+       let ctrl = null;
+       const fetch = useCallback(debounce(async phrase => {
+               if (ctrl) {
+                       ctrl.abort();
+               }
+               ctrl = new AbortController();
+               try {
+                       const response = await axios.get(`/api/channels`, {
+                               params: {
+                                       joinable: joinable ? 1 : 0,
+                                       limit: 5,
+                                       manageable: manageable ? 1 : 0,
+                                       phrase,
+                               },
+                               signal: ctrl.signal,
+                       });
+                       ctrl = null;
+                       setResults(response.data);
+                       if (autoSelect && !phrase && response.data.length === 1) {
+                               onChange({
+                                       channel: response.data[0],
+                                       target: { value: response.data[0].id },
+                               });
+                       }
+               } catch (e) {
+                       ctrl = null;
+                       console.error(e);
+               }
+       }, 300), [autoSelect, joinable, manageable]);
+
+       useEffect(() => {
+               fetch(search);
+       }, [search]);
+
+       useEffect(() => {
+               if (value) {
+                       axios
+                               .get(`/api/channels/${value}`)
+                       .then(response => {
+                               setResolved(response.data);
+                       });
+               } else {
+                       setResolved(null);
+               }
+       }, [value]);
+
+       if (value) {
+               return <div className="d-flex align-items-center justify-content-between">
+                       <span>{resolved ? resolved.title : value}</span>
+                       {!readOnly ?
+                               <Button
+                                       className="ms-2"
+                                       onClick={() => onChange({ channel: null, target: { value: '' }})}
+                                       title={t('button.unset')}
+                                       variant="outline-danger"
+                               >
+                                       <Icon.REMOVE title="" />
+                               </Button>
+                       : null}
+               </div>;
+       }
+       return <div className={`channel-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+               <Form.Control
+                       className="search-input"
+                       name={Math.random().toString(20).substr(2, 10)}
+                       onChange={e => setSearch(e.target.value)}
+                       onFocus={() => setShowResults(true)}
+                       readOnly={readOnly}
+                       type="search"
+                       value={search}
+               />
+               <div className="search-results-holder">
+                       {results.length ?
+                               <ListGroup className="search-results">
+                                       {results.map(result =>
+                                               <ListGroup.Item
+                                                       action
+                                                       key={result.id}
+                                                       onClick={() => onChange({
+                                                               channel: result,
+                                                               target: { value: result.id },
+                                                       })}
+                                               >
+                                                       {result.title}
+                                               </ListGroup.Item>
+                                       )}
+                               </ListGroup>
+                       :
+                               <Alert className="search-results" variant="info">
+                                       {t('search.noResults')}
+                               </Alert>
+                       }
+               </div>
+       </div>;
+};
+
+ChannelSelect.propTypes = {
+       autoSelect: PropTypes.bool,
+       joinable: PropTypes.bool,
+       manageable: PropTypes.bool,
+       onChange: PropTypes.func,
+       readOnly: PropTypes.bool,
+       value: PropTypes.oneOfType([
+               PropTypes.number,
+               PropTypes.string,
+       ]),
+};
+
+export default ChannelSelect;
diff --git a/resources/js/components/common/DiscordChannelSelect.js b/resources/js/components/common/DiscordChannelSelect.js
deleted file mode 100644 (file)
index 01ee3b0..0000000
+++ /dev/null
@@ -1,138 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useCallback, useEffect, useState } from 'react';
-import { Alert, Button, Form, ListGroup } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from './Icon';
-import ChannelBox from '../discord-guilds/ChannelBox';
-import debounce from '../../helpers/debounce';
-
-const DiscordChannelSelect = ({
-       guild,
-       name,
-       onChange,
-       types,
-       value,
-}) => {
-       const [resolved, setResolved] = useState(null);
-       const [results, setResults] = useState([]);
-       const [search, setSearch] = useState('');
-       const [showResults, setShowResults] = useState(false);
-
-       const ref = React.useRef(null);
-       const { t } = useTranslation();
-
-       useEffect(() => {
-               const handleEventOutside = e => {
-                       if (ref.current && !ref.current.contains(e.target)) {
-                               setShowResults(false);
-                       }
-               };
-               document.addEventListener('mousedown', handleEventOutside, true);
-               document.addEventListener('focus', handleEventOutside, true);
-               return () => {
-                       document.removeEventListener('mousedown', handleEventOutside, true);
-                       document.removeEventListener('focus', handleEventOutside, true);
-               };
-       }, []);
-
-       let ctrl = null;
-       const fetch = useCallback(debounce(async (guild, phrase, types) => {
-               if (ctrl) {
-                       ctrl.abort();
-               }
-               ctrl = new AbortController();
-               try {
-                       const response = await axios.get(`/api/discord-guilds/${guild}/channels`, {
-                               params: {
-                                       phrase,
-                                       types,
-                               },
-                               signal: ctrl.signal,
-                       });
-                       ctrl = null;
-                       setResults(response.data);
-               } catch (e) {
-                       ctrl = null;
-                       console.error(e);
-               }
-               return () => {
-                       if (ctrl) ctrl.abort();
-               };
-       }, 300), []);
-
-       useEffect(() => {
-               fetch(guild, search, types);
-       }, [guild, search, ...types]);
-
-       useEffect(() => {
-               if (value) {
-                       axios
-                               .get(`/api/discord-channels/${value}`)
-                       .then(response => {
-                               setResolved(response.data);
-                       });
-               } else {
-                       setResolved(null);
-               }
-       }, [value]);
-
-       if (value) {
-               return <div className="d-flex align-items-center justify-content-between">
-                       <span>{resolved ? <ChannelBox channel={resolved} /> : value}</span>
-                       <Button
-                               className="ms-2"
-                               onClick={() => onChange({ guild: null, target: { name, value: '' }})}
-                               title={t('button.unset')}
-                               variant="outline-danger"
-                       >
-                               <Icon.REMOVE title="" />
-                       </Button>
-               </div>;
-       }
-       return <div className={`discord-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
-               <Form.Control
-                       className="search-input"
-                       name={Math.random().toString(20).substr(2, 10)}
-                       onChange={e => setSearch(e.target.value)}
-                       onFocus={() => setShowResults(true)}
-                       type="search"
-                       value={search}
-               />
-               <div className="search-results-holder">
-                       {results.length ?
-                               <ListGroup className="search-results">
-                                       {results.map(result =>
-                                               <ListGroup.Item
-                                                       action
-                                                       key={result.id}
-                                                       onClick={() => onChange({
-                                                               channel: result,
-                                                               target: { name, value: result.channel_id },
-                                                       })}
-                                               >
-                                                       <ChannelBox channel={result} />
-                                               </ListGroup.Item>
-                                       )}
-                               </ListGroup>
-                       :
-                               <Alert className="search-results" variant="info">
-                                       {t('search.noResults')}
-                               </Alert>
-                       }
-               </div>
-       </div>;
-};
-
-DiscordChannelSelect.propTypes = {
-       guild: PropTypes.string,
-       isInvalid: PropTypes.bool,
-       name: PropTypes.string,
-       onBlur: PropTypes.func,
-       onChange: PropTypes.func,
-       types: PropTypes.arrayOf(PropTypes.number),
-       value: PropTypes.string,
-};
-
-export default DiscordChannelSelect;
diff --git a/resources/js/components/common/DiscordChannelSelect.jsx b/resources/js/components/common/DiscordChannelSelect.jsx
new file mode 100644 (file)
index 0000000..01ee3b0
--- /dev/null
@@ -0,0 +1,138 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useState } from 'react';
+import { Alert, Button, Form, ListGroup } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from './Icon';
+import ChannelBox from '../discord-guilds/ChannelBox';
+import debounce from '../../helpers/debounce';
+
+const DiscordChannelSelect = ({
+       guild,
+       name,
+       onChange,
+       types,
+       value,
+}) => {
+       const [resolved, setResolved] = useState(null);
+       const [results, setResults] = useState([]);
+       const [search, setSearch] = useState('');
+       const [showResults, setShowResults] = useState(false);
+
+       const ref = React.useRef(null);
+       const { t } = useTranslation();
+
+       useEffect(() => {
+               const handleEventOutside = e => {
+                       if (ref.current && !ref.current.contains(e.target)) {
+                               setShowResults(false);
+                       }
+               };
+               document.addEventListener('mousedown', handleEventOutside, true);
+               document.addEventListener('focus', handleEventOutside, true);
+               return () => {
+                       document.removeEventListener('mousedown', handleEventOutside, true);
+                       document.removeEventListener('focus', handleEventOutside, true);
+               };
+       }, []);
+
+       let ctrl = null;
+       const fetch = useCallback(debounce(async (guild, phrase, types) => {
+               if (ctrl) {
+                       ctrl.abort();
+               }
+               ctrl = new AbortController();
+               try {
+                       const response = await axios.get(`/api/discord-guilds/${guild}/channels`, {
+                               params: {
+                                       phrase,
+                                       types,
+                               },
+                               signal: ctrl.signal,
+                       });
+                       ctrl = null;
+                       setResults(response.data);
+               } catch (e) {
+                       ctrl = null;
+                       console.error(e);
+               }
+               return () => {
+                       if (ctrl) ctrl.abort();
+               };
+       }, 300), []);
+
+       useEffect(() => {
+               fetch(guild, search, types);
+       }, [guild, search, ...types]);
+
+       useEffect(() => {
+               if (value) {
+                       axios
+                               .get(`/api/discord-channels/${value}`)
+                       .then(response => {
+                               setResolved(response.data);
+                       });
+               } else {
+                       setResolved(null);
+               }
+       }, [value]);
+
+       if (value) {
+               return <div className="d-flex align-items-center justify-content-between">
+                       <span>{resolved ? <ChannelBox channel={resolved} /> : value}</span>
+                       <Button
+                               className="ms-2"
+                               onClick={() => onChange({ guild: null, target: { name, value: '' }})}
+                               title={t('button.unset')}
+                               variant="outline-danger"
+                       >
+                               <Icon.REMOVE title="" />
+                       </Button>
+               </div>;
+       }
+       return <div className={`discord-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+               <Form.Control
+                       className="search-input"
+                       name={Math.random().toString(20).substr(2, 10)}
+                       onChange={e => setSearch(e.target.value)}
+                       onFocus={() => setShowResults(true)}
+                       type="search"
+                       value={search}
+               />
+               <div className="search-results-holder">
+                       {results.length ?
+                               <ListGroup className="search-results">
+                                       {results.map(result =>
+                                               <ListGroup.Item
+                                                       action
+                                                       key={result.id}
+                                                       onClick={() => onChange({
+                                                               channel: result,
+                                                               target: { name, value: result.channel_id },
+                                                       })}
+                                               >
+                                                       <ChannelBox channel={result} />
+                                               </ListGroup.Item>
+                                       )}
+                               </ListGroup>
+                       :
+                               <Alert className="search-results" variant="info">
+                                       {t('search.noResults')}
+                               </Alert>
+                       }
+               </div>
+       </div>;
+};
+
+DiscordChannelSelect.propTypes = {
+       guild: PropTypes.string,
+       isInvalid: PropTypes.bool,
+       name: PropTypes.string,
+       onBlur: PropTypes.func,
+       onChange: PropTypes.func,
+       types: PropTypes.arrayOf(PropTypes.number),
+       value: PropTypes.string,
+};
+
+export default DiscordChannelSelect;
diff --git a/resources/js/components/common/DiscordSelect.js b/resources/js/components/common/DiscordSelect.js
deleted file mode 100644 (file)
index 9ac7b56..0000000
+++ /dev/null
@@ -1,123 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { Alert, Button, Form, ListGroup } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from './Icon';
-import GuildBox from '../discord-guilds/Box';
-import debounce from '../../helpers/debounce';
-
-const DiscordSelect = ({ onChange, value }) => {
-       const [resolved, setResolved] = useState(null);
-       const [results, setResults] = useState([]);
-       const [search, setSearch] = useState('');
-       const [showResults, setShowResults] = useState(false);
-
-       const ref = useRef(null);
-       const { t } = useTranslation();
-
-       useEffect(() => {
-               const handleEventOutside = e => {
-                       if (ref.current && !ref.current.contains(e.target)) {
-                               setShowResults(false);
-                       }
-               };
-               document.addEventListener('mousedown', handleEventOutside, true);
-               document.addEventListener('focus', handleEventOutside, true);
-               return () => {
-                       document.removeEventListener('mousedown', handleEventOutside, true);
-                       document.removeEventListener('focus', handleEventOutside, true);
-               };
-       }, []);
-
-       let ctrl = null;
-       const fetch = useCallback(debounce(async phrase => {
-               if (ctrl) {
-                       ctrl.abort();
-               }
-               ctrl = new AbortController();
-               try {
-                       const response = await axios.get(`/api/discord-guilds`, {
-                               params: {
-                                       phrase,
-                               },
-                               signal: ctrl.signal,
-                       });
-                       ctrl = null;
-                       setResults(response.data);
-               } catch (e) {
-                       ctrl = null;
-                       console.error(e);
-               }
-       }, 300), []);
-
-       useEffect(() => {
-               fetch(search);
-       }, [search]);
-
-       useEffect(() => {
-               if (value) {
-                       axios
-                               .get(`/api/discord-guilds/${value}`)
-                       .then(response => {
-                               setResolved(response.data);
-                       });
-               } else {
-                       setResolved(null);
-               }
-       }, [value]);
-
-       if (value) {
-               return <div className="d-flex align-items-center justify-content-between">
-                       <span>{resolved ? <GuildBox guild={resolved} /> : value}</span>
-                       <Button
-                               className="ms-2"
-                               onClick={() => onChange({ guild: null, target: { value: '' }})}
-                               title={t('button.unset')}
-                               variant="outline-danger"
-                       >
-                               <Icon.REMOVE title="" />
-                       </Button>
-               </div>;
-       }
-       return <div className={`discord-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
-               <Form.Control
-                       className="search-input"
-                       name={Math.random().toString(20).substr(2, 10)}
-                       onChange={e => setSearch(e.target.value)}
-                       onFocus={() => setShowResults(true)}
-                       type="search"
-                       value={search}
-               />
-               <div className="search-results-holder">
-                       {results.length ?
-                               <ListGroup className="search-results">
-                                       {results.map(result =>
-                                               <ListGroup.Item
-                                                       action
-                                                       key={result.id}
-                                                       onClick={() => onChange({
-                                                               guild: result,
-                                                               target: { value: result.guild_id },
-                                                       })}
-                                               >
-                                                       <GuildBox guild={result} />
-                                               </ListGroup.Item>
-                                       )}
-                               </ListGroup>
-                       :
-                               <Alert className="search-results" variant="info">
-                                       {t('search.noResults')}
-                               </Alert>
-                       }
-               </div>
-       </div>;
-};
-
-DiscordSelect.propTypes = {
-       onChange: PropTypes.func,
-       value: PropTypes.string,
-};
-
-export default DiscordSelect;
diff --git a/resources/js/components/common/DiscordSelect.jsx b/resources/js/components/common/DiscordSelect.jsx
new file mode 100644 (file)
index 0000000..9ac7b56
--- /dev/null
@@ -0,0 +1,123 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Alert, Button, Form, ListGroup } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from './Icon';
+import GuildBox from '../discord-guilds/Box';
+import debounce from '../../helpers/debounce';
+
+const DiscordSelect = ({ onChange, value }) => {
+       const [resolved, setResolved] = useState(null);
+       const [results, setResults] = useState([]);
+       const [search, setSearch] = useState('');
+       const [showResults, setShowResults] = useState(false);
+
+       const ref = useRef(null);
+       const { t } = useTranslation();
+
+       useEffect(() => {
+               const handleEventOutside = e => {
+                       if (ref.current && !ref.current.contains(e.target)) {
+                               setShowResults(false);
+                       }
+               };
+               document.addEventListener('mousedown', handleEventOutside, true);
+               document.addEventListener('focus', handleEventOutside, true);
+               return () => {
+                       document.removeEventListener('mousedown', handleEventOutside, true);
+                       document.removeEventListener('focus', handleEventOutside, true);
+               };
+       }, []);
+
+       let ctrl = null;
+       const fetch = useCallback(debounce(async phrase => {
+               if (ctrl) {
+                       ctrl.abort();
+               }
+               ctrl = new AbortController();
+               try {
+                       const response = await axios.get(`/api/discord-guilds`, {
+                               params: {
+                                       phrase,
+                               },
+                               signal: ctrl.signal,
+                       });
+                       ctrl = null;
+                       setResults(response.data);
+               } catch (e) {
+                       ctrl = null;
+                       console.error(e);
+               }
+       }, 300), []);
+
+       useEffect(() => {
+               fetch(search);
+       }, [search]);
+
+       useEffect(() => {
+               if (value) {
+                       axios
+                               .get(`/api/discord-guilds/${value}`)
+                       .then(response => {
+                               setResolved(response.data);
+                       });
+               } else {
+                       setResolved(null);
+               }
+       }, [value]);
+
+       if (value) {
+               return <div className="d-flex align-items-center justify-content-between">
+                       <span>{resolved ? <GuildBox guild={resolved} /> : value}</span>
+                       <Button
+                               className="ms-2"
+                               onClick={() => onChange({ guild: null, target: { value: '' }})}
+                               title={t('button.unset')}
+                               variant="outline-danger"
+                       >
+                               <Icon.REMOVE title="" />
+                       </Button>
+               </div>;
+       }
+       return <div className={`discord-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+               <Form.Control
+                       className="search-input"
+                       name={Math.random().toString(20).substr(2, 10)}
+                       onChange={e => setSearch(e.target.value)}
+                       onFocus={() => setShowResults(true)}
+                       type="search"
+                       value={search}
+               />
+               <div className="search-results-holder">
+                       {results.length ?
+                               <ListGroup className="search-results">
+                                       {results.map(result =>
+                                               <ListGroup.Item
+                                                       action
+                                                       key={result.id}
+                                                       onClick={() => onChange({
+                                                               guild: result,
+                                                               target: { value: result.guild_id },
+                                                       })}
+                                               >
+                                                       <GuildBox guild={result} />
+                                               </ListGroup.Item>
+                                       )}
+                               </ListGroup>
+                       :
+                               <Alert className="search-results" variant="info">
+                                       {t('search.noResults')}
+                               </Alert>
+                       }
+               </div>
+       </div>;
+};
+
+DiscordSelect.propTypes = {
+       onChange: PropTypes.func,
+       value: PropTypes.string,
+};
+
+export default DiscordSelect;
diff --git a/resources/js/components/common/ErrorBoundary.js b/resources/js/components/common/ErrorBoundary.js
deleted file mode 100644 (file)
index 83fdb3e..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import ErrorMessage from './ErrorMessage';
-
-class ErrorBoundary extends React.Component {
-       constructor(props) {
-               super(props);
-               this.state = {
-                       error: null,
-               };
-       }
-
-       static getDerivedStateFromError(error) {
-               return { error };
-       }
-
-       componentDidCatch(error, errorInfo) {
-               console.log(error, errorInfo);
-       }
-
-       render() {
-               const { children } = this.props;
-               const { error } = this.state;
-               if (error) {
-                       return <ErrorMessage error={error} />;
-               }
-               return children;
-       }
-}
-
-ErrorBoundary.propTypes = {
-       children: PropTypes.node,
-};
-
-export default ErrorBoundary;
diff --git a/resources/js/components/common/ErrorBoundary.jsx b/resources/js/components/common/ErrorBoundary.jsx
new file mode 100644 (file)
index 0000000..83fdb3e
--- /dev/null
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import ErrorMessage from './ErrorMessage';
+
+class ErrorBoundary extends React.Component {
+       constructor(props) {
+               super(props);
+               this.state = {
+                       error: null,
+               };
+       }
+
+       static getDerivedStateFromError(error) {
+               return { error };
+       }
+
+       componentDidCatch(error, errorInfo) {
+               console.log(error, errorInfo);
+       }
+
+       render() {
+               const { children } = this.props;
+               const { error } = this.state;
+               if (error) {
+                       return <ErrorMessage error={error} />;
+               }
+               return children;
+       }
+}
+
+ErrorBoundary.propTypes = {
+       children: PropTypes.node,
+};
+
+export default ErrorBoundary;
diff --git a/resources/js/components/common/ErrorMessage.js b/resources/js/components/common/ErrorMessage.js
deleted file mode 100644 (file)
index 2430bc2..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import i18n from '../../i18n';
-
-const ErrorMessage = ({ error }) => {
-       if (error.response) {
-               return <Alert variant="danger">
-                       <Alert.Heading>{i18n.t(`error.${error.response.status}.heading`)}</Alert.Heading>
-                       <p className="mb-0">{i18n.t(`error.${error.response.status}.description`)}</p>
-               </Alert>;
-       }
-       if (error.message) {
-               return <Alert variant="danger">
-                       <Alert.Heading>Error</Alert.Heading>
-                       <p className="mb-0">{error.message}</p>
-               </Alert>;
-       }
-       return <div className="error">Error</div>;
-};
-
-ErrorMessage.propTypes = {
-       error: PropTypes.shape({
-               message: PropTypes.string,
-               request: PropTypes.shape({}),
-               response: PropTypes.shape({
-                       status: PropTypes.number,
-               }),
-       }),
-};
-
-export default withTranslation()(ErrorMessage);
diff --git a/resources/js/components/common/ErrorMessage.jsx b/resources/js/components/common/ErrorMessage.jsx
new file mode 100644 (file)
index 0000000..2430bc2
--- /dev/null
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import i18n from '../../i18n';
+
+const ErrorMessage = ({ error }) => {
+       if (error.response) {
+               return <Alert variant="danger">
+                       <Alert.Heading>{i18n.t(`error.${error.response.status}.heading`)}</Alert.Heading>
+                       <p className="mb-0">{i18n.t(`error.${error.response.status}.description`)}</p>
+               </Alert>;
+       }
+       if (error.message) {
+               return <Alert variant="danger">
+                       <Alert.Heading>Error</Alert.Heading>
+                       <p className="mb-0">{error.message}</p>
+               </Alert>;
+       }
+       return <div className="error">Error</div>;
+};
+
+ErrorMessage.propTypes = {
+       error: PropTypes.shape({
+               message: PropTypes.string,
+               request: PropTypes.shape({}),
+               response: PropTypes.shape({
+                       status: PropTypes.number,
+               }),
+       }),
+};
+
+export default withTranslation()(ErrorMessage);
diff --git a/resources/js/components/common/HTMLInput.js b/resources/js/components/common/HTMLInput.js
deleted file mode 100644 (file)
index 8a54842..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-import { html } from '@codemirror/lang-html';
-import { EditorView } from '@codemirror/view';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { githubDark } from '@uiw/codemirror-theme-github';
-import CodeMirror from '@uiw/react-codemirror';
-
-const HTMLInput = ({
-       name,
-       onChange,
-       value,
-}) => {
-       const handleChange = React.useCallback((value) => {
-               return onChange({ target: { name, value } });
-       }, [name, onChange]);
-
-       return <CodeMirror
-               extensions={[html(), EditorView.lineWrapping]}
-               onChange={handleChange}
-               theme={githubDark}
-               value={value}
-       />;
-};
-
-HTMLInput.propTypes = {
-       name: PropTypes.string,
-       onChange: PropTypes.func,
-       value: PropTypes.string,
-};
-
-export default HTMLInput;
diff --git a/resources/js/components/common/HTMLInput.jsx b/resources/js/components/common/HTMLInput.jsx
new file mode 100644 (file)
index 0000000..8a54842
--- /dev/null
@@ -0,0 +1,31 @@
+import { html } from '@codemirror/lang-html';
+import { EditorView } from '@codemirror/view';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { githubDark } from '@uiw/codemirror-theme-github';
+import CodeMirror from '@uiw/react-codemirror';
+
+const HTMLInput = ({
+       name,
+       onChange,
+       value,
+}) => {
+       const handleChange = React.useCallback((value) => {
+               return onChange({ target: { name, value } });
+       }, [name, onChange]);
+
+       return <CodeMirror
+               extensions={[html(), EditorView.lineWrapping]}
+               onChange={handleChange}
+               theme={githubDark}
+               value={value}
+       />;
+};
+
+HTMLInput.propTypes = {
+       name: PropTypes.string,
+       onChange: PropTypes.func,
+       value: PropTypes.string,
+};
+
+export default HTMLInput;
diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js
deleted file mode 100644 (file)
index ff676c1..0000000
+++ /dev/null
@@ -1,109 +0,0 @@
-import { library } from '@fortawesome/fontawesome-svg-core';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { fab } from '@fortawesome/free-brands-svg-icons';
-import { fas } from '@fortawesome/free-solid-svg-icons';
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import i18n from '../../i18n';
-
-library.add(fab);
-library.add(fas);
-
-const Icon = ({
-       alt = null,
-       className = '',
-       name,
-       size = null,
-       title = null,
-}) =>
-       <FontAwesomeIcon
-               icon={name}
-               alt={alt}
-               className={name === Icon.LOADING ? `${className} fa-spin` : className}
-               size={size}
-               title={title}
-       />
-;
-
-Icon.propTypes = {
-       name: PropTypes.oneOfType([
-               PropTypes.string,
-               PropTypes.arrayOf(PropTypes.string),
-       ]).isRequired,
-       alt: PropTypes.string,
-       className: PropTypes.string,
-       size: PropTypes.string,
-       title: PropTypes.string,
-};
-
-const makePreset = (presetDisplayName, presetName) => {
-       const preset = ({ alt, className, name, size, title}) => <Icon
-               alt={alt || i18n.t(`icon.${presetDisplayName}`)}
-               className={className}
-               name={name || presetName}
-               size={size}
-               title={title !== '' ? title || alt || i18n.t(`icon.${presetDisplayName}`) : null}
-       />;
-       preset.displayName = presetDisplayName;
-       return withTranslation()(preset);
-};
-
-Icon.ACCEPT = makePreset('AcceptIcon', 'square-check');
-Icon.ADD = makePreset('AddIcon', 'circle-plus');
-Icon.ALLOWED = makePreset('AllowedIcon', 'square-check');
-Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket');
-Icon.APPLICATIONS = makePreset('ApplicationsIcon', 'person-running');
-Icon.BROWSER_SOURCE = makePreset('BrowserSourceIcon', 'tv');
-Icon.CHART = makePreset('ChartIcon', 'chart-line');
-Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs');
-Icon.DELETE = makePreset('DeleteIcon', 'user-xmark');
-Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
-Icon.EDIT = makePreset('EditIcon', 'edit');
-Icon.FILTER = makePreset('FilterIcon', 'filter');
-Icon.FINISHED = makePreset('FinishedIcon', 'square-check');
-Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy');
-Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark');
-Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark');
-Icon.HASH = makePreset('HashIcon', 'hashtag');
-Icon.INFO = makePreset('Info', 'circle-info');
-Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke');
-Icon.LANGUAGE = makePreset('LanguageIcon', 'language');
-Icon.LOAD = makePreset('LoadIcon', 'upload');
-Icon.LOCKED = makePreset('LockedIcon', 'lock');
-Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
-Icon.MENU = makePreset('MenuIcon', 'bars');
-Icon.MICROPHONE = makePreset('MicrophoneIcon', 'microphone');
-Icon.MONITOR = makePreset('MonitorIcon', 'tv');
-Icon.MOUSE = makePreset('MouseIcon', 'arrow-pointer');
-Icon.OPEN = makePreset('OpenIcon', 'arrow-up-right-from-square');
-Icon.PAUSE = makePreset('PauseIcon', 'pause');
-Icon.PENDING = makePreset('PendingIcon', 'clock');
-Icon.PIN = makePreset('PinIcon', 'location-pin');
-Icon.PLAY = makePreset('PlayIcon', 'play');
-Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt');
-Icon.RACETIME = makePreset('RacetimeIcon', 'stopwatch');
-Icon.REJECT = makePreset('RejectIcon', 'square-xmark');
-Icon.REMOVE = makePreset('RemoveIcon', 'square-xmark');
-Icon.RESET = makePreset('ResetIcon', 'rotate-left');
-Icon.RESULT = makePreset('ResultIcon', 'clock');
-Icon.SAVE = makePreset('SaveIcon', 'download');
-Icon.SECOND_PLACE = makePreset('SecondPlaceIcon', 'medal');
-Icon.SETTINGS = makePreset('SettingsIcon', 'cog');
-Icon.SLASH = makePreset('SlashIcon', 'slash');
-Icon.STEP_BACKWARD = makePreset('StepBackwardIcon', 'backward-step');
-Icon.STEP_FORWARD = makePreset('StepForwardIcon', 'forward-step');
-Icon.STOP = makePreset('StopIcon', 'stop');
-Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']);
-Icon.THIRD_PLACE = makePreset('ThirdPlaceIcon', 'award');
-Icon.TIME_REVERSE = makePreset('TimeReverseIcon', 'clock-rotate-left');
-Icon.TWITCH = makePreset('TwitchIcon', ['fab', 'twitch']);
-Icon.UNKNOWN = makePreset('UnknownIcon', 'square-question');
-Icon.UNLOCKED = makePreset('UnlockedIcon', 'lock-open');
-Icon.VIDEO = makePreset('VideoIcon', 'video');
-Icon.WARNING = makePreset('WarningIcon', 'triangle-exclamation');
-Icon.VOLUME = makePreset('VolumeIcon', 'volume-high');
-Icon.YOUTUBE = makePreset('YoutubeIcon', ['fab', 'youtube']);
-
-export default Icon;
diff --git a/resources/js/components/common/Icon.jsx b/resources/js/components/common/Icon.jsx
new file mode 100644 (file)
index 0000000..ff676c1
--- /dev/null
@@ -0,0 +1,109 @@
+import { library } from '@fortawesome/fontawesome-svg-core';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { fab } from '@fortawesome/free-brands-svg-icons';
+import { fas } from '@fortawesome/free-solid-svg-icons';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import i18n from '../../i18n';
+
+library.add(fab);
+library.add(fas);
+
+const Icon = ({
+       alt = null,
+       className = '',
+       name,
+       size = null,
+       title = null,
+}) =>
+       <FontAwesomeIcon
+               icon={name}
+               alt={alt}
+               className={name === Icon.LOADING ? `${className} fa-spin` : className}
+               size={size}
+               title={title}
+       />
+;
+
+Icon.propTypes = {
+       name: PropTypes.oneOfType([
+               PropTypes.string,
+               PropTypes.arrayOf(PropTypes.string),
+       ]).isRequired,
+       alt: PropTypes.string,
+       className: PropTypes.string,
+       size: PropTypes.string,
+       title: PropTypes.string,
+};
+
+const makePreset = (presetDisplayName, presetName) => {
+       const preset = ({ alt, className, name, size, title}) => <Icon
+               alt={alt || i18n.t(`icon.${presetDisplayName}`)}
+               className={className}
+               name={name || presetName}
+               size={size}
+               title={title !== '' ? title || alt || i18n.t(`icon.${presetDisplayName}`) : null}
+       />;
+       preset.displayName = presetDisplayName;
+       return withTranslation()(preset);
+};
+
+Icon.ACCEPT = makePreset('AcceptIcon', 'square-check');
+Icon.ADD = makePreset('AddIcon', 'circle-plus');
+Icon.ALLOWED = makePreset('AllowedIcon', 'square-check');
+Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket');
+Icon.APPLICATIONS = makePreset('ApplicationsIcon', 'person-running');
+Icon.BROWSER_SOURCE = makePreset('BrowserSourceIcon', 'tv');
+Icon.CHART = makePreset('ChartIcon', 'chart-line');
+Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs');
+Icon.DELETE = makePreset('DeleteIcon', 'user-xmark');
+Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
+Icon.EDIT = makePreset('EditIcon', 'edit');
+Icon.FILTER = makePreset('FilterIcon', 'filter');
+Icon.FINISHED = makePreset('FinishedIcon', 'square-check');
+Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy');
+Icon.FORBIDDEN = makePreset('ForbiddenIcon', 'square-xmark');
+Icon.FORFEIT = makePreset('ForfeitIcon', 'square-xmark');
+Icon.HASH = makePreset('HashIcon', 'hashtag');
+Icon.INFO = makePreset('Info', 'circle-info');
+Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke');
+Icon.LANGUAGE = makePreset('LanguageIcon', 'language');
+Icon.LOAD = makePreset('LoadIcon', 'upload');
+Icon.LOCKED = makePreset('LockedIcon', 'lock');
+Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
+Icon.MENU = makePreset('MenuIcon', 'bars');
+Icon.MICROPHONE = makePreset('MicrophoneIcon', 'microphone');
+Icon.MONITOR = makePreset('MonitorIcon', 'tv');
+Icon.MOUSE = makePreset('MouseIcon', 'arrow-pointer');
+Icon.OPEN = makePreset('OpenIcon', 'arrow-up-right-from-square');
+Icon.PAUSE = makePreset('PauseIcon', 'pause');
+Icon.PENDING = makePreset('PendingIcon', 'clock');
+Icon.PIN = makePreset('PinIcon', 'location-pin');
+Icon.PLAY = makePreset('PlayIcon', 'play');
+Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt');
+Icon.RACETIME = makePreset('RacetimeIcon', 'stopwatch');
+Icon.REJECT = makePreset('RejectIcon', 'square-xmark');
+Icon.REMOVE = makePreset('RemoveIcon', 'square-xmark');
+Icon.RESET = makePreset('ResetIcon', 'rotate-left');
+Icon.RESULT = makePreset('ResultIcon', 'clock');
+Icon.SAVE = makePreset('SaveIcon', 'download');
+Icon.SECOND_PLACE = makePreset('SecondPlaceIcon', 'medal');
+Icon.SETTINGS = makePreset('SettingsIcon', 'cog');
+Icon.SLASH = makePreset('SlashIcon', 'slash');
+Icon.STEP_BACKWARD = makePreset('StepBackwardIcon', 'backward-step');
+Icon.STEP_FORWARD = makePreset('StepForwardIcon', 'forward-step');
+Icon.STOP = makePreset('StopIcon', 'stop');
+Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']);
+Icon.THIRD_PLACE = makePreset('ThirdPlaceIcon', 'award');
+Icon.TIME_REVERSE = makePreset('TimeReverseIcon', 'clock-rotate-left');
+Icon.TWITCH = makePreset('TwitchIcon', ['fab', 'twitch']);
+Icon.UNKNOWN = makePreset('UnknownIcon', 'square-question');
+Icon.UNLOCKED = makePreset('UnlockedIcon', 'lock-open');
+Icon.VIDEO = makePreset('VideoIcon', 'video');
+Icon.WARNING = makePreset('WarningIcon', 'triangle-exclamation');
+Icon.VOLUME = makePreset('VolumeIcon', 'volume-high');
+Icon.YOUTUBE = makePreset('YoutubeIcon', ['fab', 'youtube']);
+
+export default Icon;
diff --git a/resources/js/components/common/LargeCheck.js b/resources/js/components/common/LargeCheck.js
deleted file mode 100644 (file)
index 9586280..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Icon from './Icon';
-
-const LargeCheck = ({
-       className = '',
-       id = '',
-       name = '',
-       onBlur = null,
-       onChange = null,
-       value = false,
-}) => {
-       let clsn = className ? `${className} custom-check` : 'custom-check';
-       if (value) {
-               clsn += ' checked';
-       }
-       return <span
-               className={clsn}
-               id={id}
-               onBlur={onBlur ? () => onBlur({ target: { name, value } }) : null}
-               onClick={onChange ? () => onChange({ target: { name, value: !value } }) : null}
-               onKeyPress={onChange ? e => {
-                       if (e.key == 'Enter' || e.key == ' ') {
-                               e.preventDefault();
-                               e.stopPropagation();
-                               onChange({ target: { name, value: !value } });
-                       }
-               } : null}
-               tabIndex="0"
-       >
-               <Icon name="check" />
-       </span>;
-};
-
-LargeCheck.propTypes = {
-       className: PropTypes.string,
-       id: PropTypes.string,
-       name: PropTypes.string,
-       onBlur: PropTypes.func,
-       onChange: PropTypes.func,
-       value: PropTypes.bool,
-};
-
-export default LargeCheck;
diff --git a/resources/js/components/common/LargeCheck.jsx b/resources/js/components/common/LargeCheck.jsx
new file mode 100644 (file)
index 0000000..9586280
--- /dev/null
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Icon from './Icon';
+
+const LargeCheck = ({
+       className = '',
+       id = '',
+       name = '',
+       onBlur = null,
+       onChange = null,
+       value = false,
+}) => {
+       let clsn = className ? `${className} custom-check` : 'custom-check';
+       if (value) {
+               clsn += ' checked';
+       }
+       return <span
+               className={clsn}
+               id={id}
+               onBlur={onBlur ? () => onBlur({ target: { name, value } }) : null}
+               onClick={onChange ? () => onChange({ target: { name, value: !value } }) : null}
+               onKeyPress={onChange ? e => {
+                       if (e.key == 'Enter' || e.key == ' ') {
+                               e.preventDefault();
+                               e.stopPropagation();
+                               onChange({ target: { name, value: !value } });
+                       }
+               } : null}
+               tabIndex="0"
+       >
+               <Icon name="check" />
+       </span>;
+};
+
+LargeCheck.propTypes = {
+       className: PropTypes.string,
+       id: PropTypes.string,
+       name: PropTypes.string,
+       onBlur: PropTypes.func,
+       onChange: PropTypes.func,
+       value: PropTypes.bool,
+};
+
+export default LargeCheck;
diff --git a/resources/js/components/common/Loading.js b/resources/js/components/common/Loading.js
deleted file mode 100644 (file)
index b850dec..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-import React from 'react';
-import { ProgressBar } from 'react-bootstrap';
-
-const Loading = () => <div className="loading">
-       <ProgressBar animated now={100} variant="info" />
-</div>;
-
-export default Loading;
diff --git a/resources/js/components/common/Loading.jsx b/resources/js/components/common/Loading.jsx
new file mode 100644 (file)
index 0000000..b850dec
--- /dev/null
@@ -0,0 +1,8 @@
+import React from 'react';
+import { ProgressBar } from 'react-bootstrap';
+
+const Loading = () => <div className="loading">
+       <ProgressBar animated now={100} variant="info" />
+</div>;
+
+export default Loading;
diff --git a/resources/js/components/common/PngDialog.js b/resources/js/components/common/PngDialog.js
deleted file mode 100644 (file)
index 1984848..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-
-import Loading from './Loading';
-
-const PngPlayer = React.lazy(() => import('./PngPlayer'));
-
-const PngDialog = ({ onHide, show, src, title }) => <Modal onHide={onHide} show={show} size="lg">
-       {title ?
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {title}
-                       </Modal.Title>
-               </Modal.Header>
-       : null}
-       <Modal.Body>
-               <React.Suspense fallback={<Loading />}>
-                       <PngPlayer src={src} />
-               </React.Suspense>
-       </Modal.Body>
-</Modal>;
-
-PngDialog.propTypes = {
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-       src: PropTypes.string,
-       title: PropTypes.string,
-};
-
-export default PngDialog;
diff --git a/resources/js/components/common/PngDialog.jsx b/resources/js/components/common/PngDialog.jsx
new file mode 100644 (file)
index 0000000..1984848
--- /dev/null
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+
+import Loading from './Loading';
+
+const PngPlayer = React.lazy(() => import('./PngPlayer'));
+
+const PngDialog = ({ onHide, show, src, title }) => <Modal onHide={onHide} show={show} size="lg">
+       {title ?
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {title}
+                       </Modal.Title>
+               </Modal.Header>
+       : null}
+       <Modal.Body>
+               <React.Suspense fallback={<Loading />}>
+                       <PngPlayer src={src} />
+               </React.Suspense>
+       </Modal.Body>
+</Modal>;
+
+PngDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+       src: PropTypes.string,
+       title: PropTypes.string,
+};
+
+export default PngDialog;
diff --git a/resources/js/components/common/PngPlayer.js b/resources/js/components/common/PngPlayer.js
deleted file mode 100644 (file)
index 3ecc53c..0000000
+++ /dev/null
@@ -1,129 +0,0 @@
-import parseApng from 'apng-js';
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from './Icon';
-
-const createPlayer = async (apng, canvas) => {
-       const context = canvas.getContext('2d', { willReadFrequently: true });
-       const player = await apng.getPlayer(context);
-       player.stop();
-       return player;
-};
-
-const PngPlayer = ({ src }) => {
-       const canvas = React.useRef();
-       const { t } = useTranslation();
-
-       const [apng, setApng] = React.useState(null);
-       const [error, setError] = React.useState(null);
-       const [frameInfo, setFrameInfo] = React.useState('');
-       const [loading, setLoading] = React.useState(true);
-       const [player, setPlayer] = React.useState(null);
-
-       React.useEffect(() => {
-               if (!src) return;
-               setError(null);
-               setLoading(true);
-               const ctrl = new AbortController();
-               const fetchPng = async () => {
-                       try {
-                               const response = await axios.get(src, {
-                                       responseType: 'arraybuffer',
-                                       signal: ctrl.signal,
-                               });
-                               const png = parseApng(response.data);
-                               await png.createImages();
-                               setApng(png);
-                               setLoading(false);
-                       } catch (e) {
-                               if (!axios.isCancel(e)) {
-                                       setError(e);
-                                       console.log(e);
-                               }
-                       }
-               };
-               fetchPng();
-               return () => {
-                       ctrl.abort();
-               };
-       }, [src]);
-
-       React.useEffect(() => {
-               if (loading || !canvas.current) return;
-               setFrameInfo(`1/${apng.frames.length}`);
-               (async () => {
-                       const p = await createPlayer(apng, canvas.current);
-                       setPlayer(p);
-                       const updateFrame = (number) => {
-                               setFrameInfo(`${number + 1}/${apng.frames.length}`);
-                       };
-                       p.on('frame', updateFrame);
-               })();
-       }, [apng, canvas.current, loading]);
-
-       const stop = React.useCallback(() => {
-               if (player) player.stop();
-       }, [player]);
-
-       const toggle = React.useCallback(() => {
-               if (!player) return;
-               if (player.paused) {
-                       player.play();
-               } else {
-                       player.pause();
-               }
-       }, [player]);
-
-       const nextFrame = React.useCallback(() => {
-               if (player) player.renderNextFrame();
-       }, [player]);
-
-       if (error) {
-               return <div>Error</div>;
-       }
-       if (loading) {
-               return <div>Loading</div>;
-       }
-
-       return <div className="png-player">
-               <div className="screen">
-                       <canvas ref={canvas} width={apng.width} height={apng.height} />
-               </div>
-               <span className="ms-auto">{frameInfo}</span>
-               <div className="button-bar controls">
-                       <Button
-                               onClick={stop}
-                               title={t('button.stop')}
-                               variant="outline-secondary"
-                       >
-                               <Icon.STOP title="" />
-                       </Button>
-                       <Button
-                               onClick={toggle}
-                               title={t('button.playPause')}
-                               variant="outline-secondary"
-                       >
-                               <Icon.PLAY title="" />
-                               {' '}
-                               <Icon.PAUSE title="" />
-                       </Button>
-                       <Button
-                               onClick={nextFrame}
-                               title={t('button.nextFrame')}
-                               variant="outline-secondary"
-                       >
-                               <Icon.STEP_FORWARD title="" />
-                       </Button>
-               </div>
-       </div>;
-};
-
-PngPlayer.propTypes = {
-       src: PropTypes.string,
-};
-
-export default PngPlayer;
diff --git a/resources/js/components/common/PngPlayer.jsx b/resources/js/components/common/PngPlayer.jsx
new file mode 100644 (file)
index 0000000..3ecc53c
--- /dev/null
@@ -0,0 +1,129 @@
+import parseApng from 'apng-js';
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from './Icon';
+
+const createPlayer = async (apng, canvas) => {
+       const context = canvas.getContext('2d', { willReadFrequently: true });
+       const player = await apng.getPlayer(context);
+       player.stop();
+       return player;
+};
+
+const PngPlayer = ({ src }) => {
+       const canvas = React.useRef();
+       const { t } = useTranslation();
+
+       const [apng, setApng] = React.useState(null);
+       const [error, setError] = React.useState(null);
+       const [frameInfo, setFrameInfo] = React.useState('');
+       const [loading, setLoading] = React.useState(true);
+       const [player, setPlayer] = React.useState(null);
+
+       React.useEffect(() => {
+               if (!src) return;
+               setError(null);
+               setLoading(true);
+               const ctrl = new AbortController();
+               const fetchPng = async () => {
+                       try {
+                               const response = await axios.get(src, {
+                                       responseType: 'arraybuffer',
+                                       signal: ctrl.signal,
+                               });
+                               const png = parseApng(response.data);
+                               await png.createImages();
+                               setApng(png);
+                               setLoading(false);
+                       } catch (e) {
+                               if (!axios.isCancel(e)) {
+                                       setError(e);
+                                       console.log(e);
+                               }
+                       }
+               };
+               fetchPng();
+               return () => {
+                       ctrl.abort();
+               };
+       }, [src]);
+
+       React.useEffect(() => {
+               if (loading || !canvas.current) return;
+               setFrameInfo(`1/${apng.frames.length}`);
+               (async () => {
+                       const p = await createPlayer(apng, canvas.current);
+                       setPlayer(p);
+                       const updateFrame = (number) => {
+                               setFrameInfo(`${number + 1}/${apng.frames.length}`);
+                       };
+                       p.on('frame', updateFrame);
+               })();
+       }, [apng, canvas.current, loading]);
+
+       const stop = React.useCallback(() => {
+               if (player) player.stop();
+       }, [player]);
+
+       const toggle = React.useCallback(() => {
+               if (!player) return;
+               if (player.paused) {
+                       player.play();
+               } else {
+                       player.pause();
+               }
+       }, [player]);
+
+       const nextFrame = React.useCallback(() => {
+               if (player) player.renderNextFrame();
+       }, [player]);
+
+       if (error) {
+               return <div>Error</div>;
+       }
+       if (loading) {
+               return <div>Loading</div>;
+       }
+
+       return <div className="png-player">
+               <div className="screen">
+                       <canvas ref={canvas} width={apng.width} height={apng.height} />
+               </div>
+               <span className="ms-auto">{frameInfo}</span>
+               <div className="button-bar controls">
+                       <Button
+                               onClick={stop}
+                               title={t('button.stop')}
+                               variant="outline-secondary"
+                       >
+                               <Icon.STOP title="" />
+                       </Button>
+                       <Button
+                               onClick={toggle}
+                               title={t('button.playPause')}
+                               variant="outline-secondary"
+                       >
+                               <Icon.PLAY title="" />
+                               {' '}
+                               <Icon.PAUSE title="" />
+                       </Button>
+                       <Button
+                               onClick={nextFrame}
+                               title={t('button.nextFrame')}
+                               variant="outline-secondary"
+                       >
+                               <Icon.STEP_FORWARD title="" />
+                       </Button>
+               </div>
+       </div>;
+};
+
+PngPlayer.propTypes = {
+       src: PropTypes.string,
+};
+
+export default PngPlayer;
diff --git a/resources/js/components/common/RawHTML.js b/resources/js/components/common/RawHTML.js
deleted file mode 100644 (file)
index dde6d53..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useNavigate } from 'react-router-dom';
-
-import PngDialog from './PngDialog';
-
-const isApng = el => el.nodeName === 'IMG' && el.getAttribute('type') === 'image/apng';
-
-const isLink = el => el.nodeName === 'A';
-
-const canClick = el => {
-       if (isLink(el)) return true;
-       if (isApng(el)) return true;
-       return false;
-};
-
-const RawHTML = ({ html }) => {
-       const navigate = useNavigate();
-       const [apng, setApng] = React.useState(null);
-       const [show, setShow] = React.useState(false);
-       const [title, setTitle] = React.useState(null);
-
-       const onClick = e => {
-               if (e.defaultPrevented) return;
-               if (e.metaKey || e.ctrlKey || e.shiftKey) return;
-               if (e.button !== 0) return;
-
-               let el = e.target;
-               while (el && !canClick(el)) {
-                       el = el.parentNode;
-               }
-               if (!el) return;
-
-               if (isLink(el)) {
-                       if (el.target && el.target !== '_self') return;
-                       if (el.attributes.download) return;
-                       if (el.rel && /(?:^|\s+)external(?:\s+|$)/.test(el.rel)) return;
-
-                       const href = el.getAttribute('href');
-
-                       if (href.startsWith('#')) return;
-                       if (href.startsWith('http')) return;
-                       if (href.startsWith('mailto')) return;
-                       if (href.startsWith('tel')) return;
-
-                       el.blur();
-                       e.preventDefault();
-
-                       setTimeout(() => {
-                               // scroll to top on location change
-                               scrollTo({ top: 0, behavior: 'smooth' });
-                       }, 50);
-
-                       navigate(href);
-                       return;
-               }
-
-               if (isApng(el)) {
-                       setApng(el.getAttribute('src'));
-                       setShow(true);
-                       setTitle(el.getAttribute('alt'));
-               }
-       };
-
-       return <>
-               <div className="raw-html" onClick={onClick} dangerouslySetInnerHTML={{ __html: html }} />
-               <PngDialog onHide={() => setShow(false)} show={show} src={apng} title={title} />
-       </>;
-};
-
-RawHTML.propTypes = {
-       html: PropTypes.string,
-};
-
-export default RawHTML;
diff --git a/resources/js/components/common/RawHTML.jsx b/resources/js/components/common/RawHTML.jsx
new file mode 100644 (file)
index 0000000..dde6d53
--- /dev/null
@@ -0,0 +1,75 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import PngDialog from './PngDialog';
+
+const isApng = el => el.nodeName === 'IMG' && el.getAttribute('type') === 'image/apng';
+
+const isLink = el => el.nodeName === 'A';
+
+const canClick = el => {
+       if (isLink(el)) return true;
+       if (isApng(el)) return true;
+       return false;
+};
+
+const RawHTML = ({ html }) => {
+       const navigate = useNavigate();
+       const [apng, setApng] = React.useState(null);
+       const [show, setShow] = React.useState(false);
+       const [title, setTitle] = React.useState(null);
+
+       const onClick = e => {
+               if (e.defaultPrevented) return;
+               if (e.metaKey || e.ctrlKey || e.shiftKey) return;
+               if (e.button !== 0) return;
+
+               let el = e.target;
+               while (el && !canClick(el)) {
+                       el = el.parentNode;
+               }
+               if (!el) return;
+
+               if (isLink(el)) {
+                       if (el.target && el.target !== '_self') return;
+                       if (el.attributes.download) return;
+                       if (el.rel && /(?:^|\s+)external(?:\s+|$)/.test(el.rel)) return;
+
+                       const href = el.getAttribute('href');
+
+                       if (href.startsWith('#')) return;
+                       if (href.startsWith('http')) return;
+                       if (href.startsWith('mailto')) return;
+                       if (href.startsWith('tel')) return;
+
+                       el.blur();
+                       e.preventDefault();
+
+                       setTimeout(() => {
+                               // scroll to top on location change
+                               scrollTo({ top: 0, behavior: 'smooth' });
+                       }, 50);
+
+                       navigate(href);
+                       return;
+               }
+
+               if (isApng(el)) {
+                       setApng(el.getAttribute('src'));
+                       setShow(true);
+                       setTitle(el.getAttribute('alt'));
+               }
+       };
+
+       return <>
+               <div className="raw-html" onClick={onClick} dangerouslySetInnerHTML={{ __html: html }} />
+               <PngDialog onHide={() => setShow(false)} show={show} src={apng} title={title} />
+       </>;
+};
+
+RawHTML.propTypes = {
+       html: PropTypes.string,
+};
+
+export default RawHTML;
diff --git a/resources/js/components/common/Slider.js b/resources/js/components/common/Slider.js
deleted file mode 100644 (file)
index 587fc7f..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-const Slider = ({ children = null, duration = 2500, vertical = false }) => {
-       const [index, setIndex] = React.useState(0);
-
-       React.useEffect(() => {
-               const interval = setInterval(() => {
-                       setIndex(i => (i + 1) % React.Children.count(children));
-               }, duration);
-               return () => {
-                       clearInterval(interval);
-               };
-       }, [React.Children.count(children), duration]);
-
-       return <div className={`slider-container ${vertical ? 'vertical' : 'horizontal'}`}>
-               <div className="slider-slides" style={{
-                       transform: vertical ? `translateY(${-index * 100}%)` : `translateX(${-index * 100}%)`
-               }}>
-                       {children}
-               </div>
-       </div>;
-};
-
-Slider.propTypes = {
-       children: PropTypes.node,
-       duration: PropTypes.number,
-       vertical: PropTypes.bool,
-};
-
-const Slide = ({ children }) => {
-       return <div className="slider-slide">
-               {children}
-       </div>;
-};
-
-Slide.propTypes = {
-       children: PropTypes.node,
-};
-
-Slider.Slide = Slide;
-
-export default Slider;
diff --git a/resources/js/components/common/Slider.jsx b/resources/js/components/common/Slider.jsx
new file mode 100644 (file)
index 0000000..587fc7f
--- /dev/null
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const Slider = ({ children = null, duration = 2500, vertical = false }) => {
+       const [index, setIndex] = React.useState(0);
+
+       React.useEffect(() => {
+               const interval = setInterval(() => {
+                       setIndex(i => (i + 1) % React.Children.count(children));
+               }, duration);
+               return () => {
+                       clearInterval(interval);
+               };
+       }, [React.Children.count(children), duration]);
+
+       return <div className={`slider-container ${vertical ? 'vertical' : 'horizontal'}`}>
+               <div className="slider-slides" style={{
+                       transform: vertical ? `translateY(${-index * 100}%)` : `translateX(${-index * 100}%)`
+               }}>
+                       {children}
+               </div>
+       </div>;
+};
+
+Slider.propTypes = {
+       children: PropTypes.node,
+       duration: PropTypes.number,
+       vertical: PropTypes.bool,
+};
+
+const Slide = ({ children }) => {
+       return <div className="slider-slide">
+               {children}
+       </div>;
+};
+
+Slide.propTypes = {
+       children: PropTypes.node,
+};
+
+Slider.Slide = Slide;
+
+export default Slider;
diff --git a/resources/js/components/common/Spoiler.js b/resources/js/components/common/Spoiler.js
deleted file mode 100644 (file)
index 084de71..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-
-const Spoiler = ({ children }) => {
-       const [show, setShow] = useState(false);
-
-       return <span
-               className={`spoiler ${show ? 'shown' : 'hidden'}`}
-               onClick={() => setShow(true)}
-       >
-               <span className="content">{children}</span>
-       </span>;
-};
-
-Spoiler.propTypes = {
-       children: PropTypes.oneOfType([
-               PropTypes.node,
-               PropTypes.object,
-               PropTypes.string,
-       ]),
-};
-
-export default Spoiler;
diff --git a/resources/js/components/common/Spoiler.jsx b/resources/js/components/common/Spoiler.jsx
new file mode 100644 (file)
index 0000000..084de71
--- /dev/null
@@ -0,0 +1,23 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+
+const Spoiler = ({ children }) => {
+       const [show, setShow] = useState(false);
+
+       return <span
+               className={`spoiler ${show ? 'shown' : 'hidden'}`}
+               onClick={() => setShow(true)}
+       >
+               <span className="content">{children}</span>
+       </span>;
+};
+
+Spoiler.propTypes = {
+       children: PropTypes.oneOfType([
+               PropTypes.node,
+               PropTypes.object,
+               PropTypes.string,
+       ]),
+};
+
+export default Spoiler;
diff --git a/resources/js/components/common/ToggleSwitch.js b/resources/js/components/common/ToggleSwitch.js
deleted file mode 100644 (file)
index 98d3d06..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Icon from './Icon';
-
-const ToggleSwitch = ({
-       isInvalid = false,
-       isValid = false,
-       name = '',
-       offLabel = '',
-       onBlur = null,
-       onChange = null,
-       onLabel = null,
-       readonly = false,
-       title = null,
-       value = false,
-}) => {
-       const toggle = () => {
-               if (readonly) return;
-               if (onChange) onChange({ target: { name, value: !value } });
-       };
-
-       const handleClick = event => {
-               event.stopPropagation();
-               toggle();
-       };
-
-       const handleKey = event => {
-               if ([13, 32].includes(event.which)) {
-                       toggle();
-                       event.preventDefault();
-                       event.stopPropagation();
-               }
-       };
-
-       const classNames = ['form-control', 'custom-toggle'];
-       if (value) classNames.push('is-toggled');
-       if (isInvalid) classNames.push('is-invalid');
-       if (isValid) classNames.push('is-valid');
-       if (readonly) classNames.push('readonly');
-
-       return <div
-                       className={classNames.join(' ')}
-                       role="button"
-                       aria-pressed={value}
-                       tabIndex="0"
-                       title={title}
-                       onBlur={onBlur ? () => onBlur({ target: { name, value } }) : null}
-                       onClick={handleClick}
-                       onKeyDown={handleKey}
-               >
-                       <div className="handle">
-                               <span className="handle-label">
-                                       {value
-                                               ? onLabel || <Icon name="check" />
-                                               : offLabel || <Icon name="times" />
-                                       }
-                               </span>
-                       </div>
-               </div>;
-};
-
-ToggleSwitch.propTypes = {
-       isInvalid: PropTypes.bool,
-       isValid: PropTypes.bool,
-       name: PropTypes.string,
-       offLabel: PropTypes.string,
-       onBlur: PropTypes.func,
-       onChange: PropTypes.func,
-       onLabel: PropTypes.string,
-       readonly: PropTypes.bool,
-       title: PropTypes.string,
-       value: PropTypes.bool,
-};
-
-export default ToggleSwitch;
diff --git a/resources/js/components/common/ToggleSwitch.jsx b/resources/js/components/common/ToggleSwitch.jsx
new file mode 100644 (file)
index 0000000..98d3d06
--- /dev/null
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Icon from './Icon';
+
+const ToggleSwitch = ({
+       isInvalid = false,
+       isValid = false,
+       name = '',
+       offLabel = '',
+       onBlur = null,
+       onChange = null,
+       onLabel = null,
+       readonly = false,
+       title = null,
+       value = false,
+}) => {
+       const toggle = () => {
+               if (readonly) return;
+               if (onChange) onChange({ target: { name, value: !value } });
+       };
+
+       const handleClick = event => {
+               event.stopPropagation();
+               toggle();
+       };
+
+       const handleKey = event => {
+               if ([13, 32].includes(event.which)) {
+                       toggle();
+                       event.preventDefault();
+                       event.stopPropagation();
+               }
+       };
+
+       const classNames = ['form-control', 'custom-toggle'];
+       if (value) classNames.push('is-toggled');
+       if (isInvalid) classNames.push('is-invalid');
+       if (isValid) classNames.push('is-valid');
+       if (readonly) classNames.push('readonly');
+
+       return <div
+                       className={classNames.join(' ')}
+                       role="button"
+                       aria-pressed={value}
+                       tabIndex="0"
+                       title={title}
+                       onBlur={onBlur ? () => onBlur({ target: { name, value } }) : null}
+                       onClick={handleClick}
+                       onKeyDown={handleKey}
+               >
+                       <div className="handle">
+                               <span className="handle-label">
+                                       {value
+                                               ? onLabel || <Icon name="check" />
+                                               : offLabel || <Icon name="times" />
+                                       }
+                               </span>
+                       </div>
+               </div>;
+};
+
+ToggleSwitch.propTypes = {
+       isInvalid: PropTypes.bool,
+       isValid: PropTypes.bool,
+       name: PropTypes.string,
+       offLabel: PropTypes.string,
+       onBlur: PropTypes.func,
+       onChange: PropTypes.func,
+       onLabel: PropTypes.string,
+       readonly: PropTypes.bool,
+       title: PropTypes.string,
+       value: PropTypes.bool,
+};
+
+export default ToggleSwitch;
diff --git a/resources/js/components/common/UserSelect.js b/resources/js/components/common/UserSelect.js
deleted file mode 100644 (file)
index 32bd186..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useCallback, useEffect, useRef, useState } from 'react';
-import { Button, Form, ListGroup } from 'react-bootstrap';
-
-import Icon from '../common/Icon';
-import UserBox from '../users/Box';
-import debounce from '../../helpers/debounce';
-
-const UserSelect = ({ name, onChange, value }) => {
-       const [resolved, setResolved] = useState(null);
-       const [results, setResults] = useState([]);
-       const [search, setSearch] = useState('');
-       const [showResults, setShowResults] = useState(false);
-
-       const ref = useRef(null);
-
-       useEffect(() => {
-               const handleEventOutside = e => {
-                       if (ref.current && !ref.current.contains(e.target)) {
-                               setShowResults(false);
-                       }
-               };
-               document.addEventListener('mousedown', handleEventOutside, true);
-               document.addEventListener('focus', handleEventOutside, true);
-               return () => {
-                       document.removeEventListener('mousedown', handleEventOutside, true);
-                       document.removeEventListener('focus', handleEventOutside, true);
-               };
-       }, []);
-
-       let ctrl = null;
-       const fetch = useCallback(debounce(async phrase => {
-               if (ctrl) {
-                       ctrl.abort();
-               }
-               ctrl = new AbortController();
-               if (!phrase || phrase.length < 3) {
-                       setResults([]);
-                       return;
-               }
-               try {
-                       const response = await axios.get(`/api/users`, {
-                               params: {
-                                       phrase,
-                               },
-                               signal: ctrl.signal,
-                       });
-                       ctrl = null;
-                       setResults(response.data);
-               } catch (e) {
-                       ctrl = null;
-                       console.error(e);
-               }
-       }, 300), []);
-
-       useEffect(() => {
-               fetch(search);
-       }, [search]);
-
-       useEffect(() => {
-               if (value) {
-                       axios
-                               .get(`/api/users/${value}`)
-                       .then(response => {
-                               setResolved(response.data);
-                       });
-               } else {
-                       setResolved(null);
-               }
-       }, [value]);
-
-       if (value) {
-               return <div className="d-flex justify-content-between">
-                       {resolved ? <UserBox discriminator noLink user={resolved} /> : <span>value</span>}
-                       <Button
-                               onClick={() => onChange({ target: { name, value: null }})}
-                               size="sm"
-                               variant="outline-danger"
-                       >
-                               <Icon.REMOVE />
-                       </Button>
-               </div>;
-       }
-       return <div className={`user-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
-               <Form.Control
-                       className="search-input"
-                       name={Math.random().toString(20).substr(2, 10)}
-                       onChange={e => setSearch(e.target.value)}
-                       onFocus={() => setShowResults(true)}
-                       type="search"
-                       value={search}
-               />
-               <div className="search-results-holder">
-                       <ListGroup className="search-results">
-                               {results.map(result =>
-                                       <ListGroup.Item
-                                               action
-                                               key={result.id}
-                                               onClick={() => onChange({
-                                                       target: { name, value: result.id },
-                                               })}
-                                       >
-                                               <UserBox discriminator noLink user={result} />
-                                       </ListGroup.Item>
-                               )}
-                       </ListGroup>
-               </div>
-       </div>;
-};
-
-UserSelect.propTypes = {
-       name: PropTypes.string,
-       onChange: PropTypes.func,
-       value: PropTypes.string,
-};
-
-export default UserSelect;
diff --git a/resources/js/components/common/UserSelect.jsx b/resources/js/components/common/UserSelect.jsx
new file mode 100644 (file)
index 0000000..32bd186
--- /dev/null
@@ -0,0 +1,118 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Button, Form, ListGroup } from 'react-bootstrap';
+
+import Icon from '../common/Icon';
+import UserBox from '../users/Box';
+import debounce from '../../helpers/debounce';
+
+const UserSelect = ({ name, onChange, value }) => {
+       const [resolved, setResolved] = useState(null);
+       const [results, setResults] = useState([]);
+       const [search, setSearch] = useState('');
+       const [showResults, setShowResults] = useState(false);
+
+       const ref = useRef(null);
+
+       useEffect(() => {
+               const handleEventOutside = e => {
+                       if (ref.current && !ref.current.contains(e.target)) {
+                               setShowResults(false);
+                       }
+               };
+               document.addEventListener('mousedown', handleEventOutside, true);
+               document.addEventListener('focus', handleEventOutside, true);
+               return () => {
+                       document.removeEventListener('mousedown', handleEventOutside, true);
+                       document.removeEventListener('focus', handleEventOutside, true);
+               };
+       }, []);
+
+       let ctrl = null;
+       const fetch = useCallback(debounce(async phrase => {
+               if (ctrl) {
+                       ctrl.abort();
+               }
+               ctrl = new AbortController();
+               if (!phrase || phrase.length < 3) {
+                       setResults([]);
+                       return;
+               }
+               try {
+                       const response = await axios.get(`/api/users`, {
+                               params: {
+                                       phrase,
+                               },
+                               signal: ctrl.signal,
+                       });
+                       ctrl = null;
+                       setResults(response.data);
+               } catch (e) {
+                       ctrl = null;
+                       console.error(e);
+               }
+       }, 300), []);
+
+       useEffect(() => {
+               fetch(search);
+       }, [search]);
+
+       useEffect(() => {
+               if (value) {
+                       axios
+                               .get(`/api/users/${value}`)
+                       .then(response => {
+                               setResolved(response.data);
+                       });
+               } else {
+                       setResolved(null);
+               }
+       }, [value]);
+
+       if (value) {
+               return <div className="d-flex justify-content-between">
+                       {resolved ? <UserBox discriminator noLink user={resolved} /> : <span>value</span>}
+                       <Button
+                               onClick={() => onChange({ target: { name, value: null }})}
+                               size="sm"
+                               variant="outline-danger"
+                       >
+                               <Icon.REMOVE />
+                       </Button>
+               </div>;
+       }
+       return <div className={`user-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+               <Form.Control
+                       className="search-input"
+                       name={Math.random().toString(20).substr(2, 10)}
+                       onChange={e => setSearch(e.target.value)}
+                       onFocus={() => setShowResults(true)}
+                       type="search"
+                       value={search}
+               />
+               <div className="search-results-holder">
+                       <ListGroup className="search-results">
+                               {results.map(result =>
+                                       <ListGroup.Item
+                                               action
+                                               key={result.id}
+                                               onClick={() => onChange({
+                                                       target: { name, value: result.id },
+                                               })}
+                                       >
+                                               <UserBox discriminator noLink user={result} />
+                                       </ListGroup.Item>
+                               )}
+                       </ListGroup>
+               </div>
+       </div>;
+};
+
+UserSelect.propTypes = {
+       name: PropTypes.string,
+       onChange: PropTypes.func,
+       value: PropTypes.string,
+};
+
+export default UserSelect;
diff --git a/resources/js/components/common/ZeldaIcon.js b/resources/js/components/common/ZeldaIcon.js
deleted file mode 100644 (file)
index 18b13eb..0000000
+++ /dev/null
@@ -1,211 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import Icon from './Icon';
-
-const ITEM_MAP = [
-    'aga',
-    'armos',
-    'arrghus',
-    'big-key',
-    'blind',
-    'blue-boomerang',
-    'blue-mail',
-    'blue-pendant',
-    'blue-potion',
-    'bombos',
-    'bomb',
-    'book',
-    'boots',
-    'bottle-bee',
-    'bottle-good-bee',
-    'bottle',
-    'bowless-silvers',
-    'bow',
-    'bugnet',
-    'bunny-head',
-    'byrna',
-    'cape',
-    'chest',
-    'compass',
-    'crystal',
-    'crystal-switch-blue',
-    'crystal-switch',
-    'crystal-switch-red',
-    'duck',
-    'ether',
-    'fairy',
-    'fighter-shield',
-    'fighter-sword',
-    'fire-rod',
-    'fire-shield',
-    'flippers',
-    'flute',
-    'ganon',
-    'glove',
-    'gold-sword',
-    'green-mail',
-    'green-pendant',
-    'green-potion',
-    'gt',
-    'half-magic',
-    'hammer',
-    'heart-0',
-    'heart-1',
-    'heart-2',
-    'heart-3',
-    'heart-container',
-    'heart-piece',
-    'helma',
-    'hookshot',
-    'ice-rod',
-    'kholdstare',
-    'lamp',
-    'lanmolas',
-    'link-head',
-    'map',
-    'master-sword',
-    'mirror',
-    'mirror-shield',
-    'mitts',
-    'moldorm',
-    'moonpearl',
-    'mothula',
-    'mushroom',
-    'open-chest',
-    'powder',
-    'quake',
-    'quarter-magic',
-    'red-bomb',
-    'red-boomerang',
-    'red-crystal',
-    'red-mail',
-    'red-pendant',
-    'red-potion',
-    'shovel',
-    'silvers',
-    'small-key',
-    'somaria',
-    'sword-1',
-    'sword-2',
-    'sword-3',
-    'sword-4',
-    'tempered-sword',
-    'triforce-piece',
-    'triforce',
-    'trinexx',
-    'vitreous',
-];
-
-const ITEM_MAP_WIDTH = 8;
-
-const ITEM_MAP_HEIGHT = Math.ceil(ITEM_MAP.length / ITEM_MAP_WIDTH);
-
-const ITEM_MAP_URL = '/items-v2.png';
-
-const isOnItemMap = name => ITEM_MAP.includes(name);
-
-const getItemMapX = name => ITEM_MAP.indexOf(name) % ITEM_MAP_WIDTH;
-
-const getItemMapY = name => Math.floor(ITEM_MAP.indexOf(name) / ITEM_MAP_WIDTH);
-
-const getItemMapStyle = name => {
-       const x = getItemMapX(name);
-       const y = getItemMapY(name);
-       return {
-               backgroundImage: `url(${ITEM_MAP_URL})`,
-               backgroundPosition: `-${x * 100}% -${y * 100}%`,
-               backgroundSize: `${ITEM_MAP_WIDTH * 100}% ${ITEM_MAP_HEIGHT * 100}%`,
-       };
-};
-
-const getIconURL = name => {
-       switch (name) {
-               case 'dungeon-ct':
-               case 'dungeon-dp':
-               case 'dungeon-ep':
-               case 'dungeon-gt':
-               case 'dungeon-hc':
-               case 'dungeon-ip':
-               case 'dungeon-mm':
-               case 'dungeon-pd':
-               case 'dungeon-sp':
-               case 'dungeon-sw':
-               case 'dungeon-th':
-               case 'dungeon-tr':
-               case 'dungeon-tt':
-                       return `/dungeon/${name.substr(8)}.png`;
-               default:
-                       return '';
-       }
-};
-
-const isHalfWidth = name => [
-       'blue-boomerang',
-       'fire-rod',
-       'ice-rod',
-       'hookshot',
-       'red-boomerang',
-].includes(name);
-
-const ZeldaIcon = ({ name, svg, title }) => {
-       const { t } = useTranslation();
-
-       const invert = name.startsWith('not-');
-       const strippedName = invert ? name.substr(4) : name;
-       const src = getIconURL(strippedName);
-       const alt = t(`icon.zelda.${name}`);
-       const realTitle = title !== '' ? title || alt : null;
-
-       if (svg) {
-               const clipX = getItemMapX(strippedName);
-               const clipY = getItemMapY(strippedName);
-               const cropX = isHalfWidth(strippedName) ? 0.25 : 0.02;
-               const cropY = 0.02;
-               const cropW = 1 - (2 * cropX);
-               const cropH = 1 - (2 * cropY);
-               return <image
-                       href={isOnItemMap(strippedName) ? ITEM_MAP_URL : src}
-                       width={ITEM_MAP_WIDTH}
-                       height={ITEM_MAP_HEIGHT}
-                       x={`-${clipX + 0.5}`}
-                       y={`-${clipY + 0.5}`}
-                       clipPath={`xywh(${clipX + cropX} ${clipY + cropY} ${cropW} ${cropH})`}
-               >
-                       {realTitle ?
-                               <title>{realTitle}</title>
-                       : null}
-               </image>;
-       }
-
-       return <span className="zelda-icon">
-               {isOnItemMap(strippedName) ?
-                       <span
-                               className="item-map-icon"
-                               style={getItemMapStyle(strippedName)}
-                               title={realTitle}
-                       />
-               : null}
-               {src ?
-                       <img
-                               alt={alt}
-                               src={src}
-                               title={realTitle}
-                       />
-               : null}
-               {invert ?
-                       <span className="strike">
-                               <Icon.SLASH title="" />
-                       </span>
-               : null}
-       </span>;
-};
-
-ZeldaIcon.propTypes = {
-       name: PropTypes.string.isRequired,
-       svg: PropTypes.bool,
-       title: PropTypes.string,
-};
-
-export default ZeldaIcon;
diff --git a/resources/js/components/common/ZeldaIcon.jsx b/resources/js/components/common/ZeldaIcon.jsx
new file mode 100644 (file)
index 0000000..18b13eb
--- /dev/null
@@ -0,0 +1,211 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Icon from './Icon';
+
+const ITEM_MAP = [
+    'aga',
+    'armos',
+    'arrghus',
+    'big-key',
+    'blind',
+    'blue-boomerang',
+    'blue-mail',
+    'blue-pendant',
+    'blue-potion',
+    'bombos',
+    'bomb',
+    'book',
+    'boots',
+    'bottle-bee',
+    'bottle-good-bee',
+    'bottle',
+    'bowless-silvers',
+    'bow',
+    'bugnet',
+    'bunny-head',
+    'byrna',
+    'cape',
+    'chest',
+    'compass',
+    'crystal',
+    'crystal-switch-blue',
+    'crystal-switch',
+    'crystal-switch-red',
+    'duck',
+    'ether',
+    'fairy',
+    'fighter-shield',
+    'fighter-sword',
+    'fire-rod',
+    'fire-shield',
+    'flippers',
+    'flute',
+    'ganon',
+    'glove',
+    'gold-sword',
+    'green-mail',
+    'green-pendant',
+    'green-potion',
+    'gt',
+    'half-magic',
+    'hammer',
+    'heart-0',
+    'heart-1',
+    'heart-2',
+    'heart-3',
+    'heart-container',
+    'heart-piece',
+    'helma',
+    'hookshot',
+    'ice-rod',
+    'kholdstare',
+    'lamp',
+    'lanmolas',
+    'link-head',
+    'map',
+    'master-sword',
+    'mirror',
+    'mirror-shield',
+    'mitts',
+    'moldorm',
+    'moonpearl',
+    'mothula',
+    'mushroom',
+    'open-chest',
+    'powder',
+    'quake',
+    'quarter-magic',
+    'red-bomb',
+    'red-boomerang',
+    'red-crystal',
+    'red-mail',
+    'red-pendant',
+    'red-potion',
+    'shovel',
+    'silvers',
+    'small-key',
+    'somaria',
+    'sword-1',
+    'sword-2',
+    'sword-3',
+    'sword-4',
+    'tempered-sword',
+    'triforce-piece',
+    'triforce',
+    'trinexx',
+    'vitreous',
+];
+
+const ITEM_MAP_WIDTH = 8;
+
+const ITEM_MAP_HEIGHT = Math.ceil(ITEM_MAP.length / ITEM_MAP_WIDTH);
+
+const ITEM_MAP_URL = '/items-v2.png';
+
+const isOnItemMap = name => ITEM_MAP.includes(name);
+
+const getItemMapX = name => ITEM_MAP.indexOf(name) % ITEM_MAP_WIDTH;
+
+const getItemMapY = name => Math.floor(ITEM_MAP.indexOf(name) / ITEM_MAP_WIDTH);
+
+const getItemMapStyle = name => {
+       const x = getItemMapX(name);
+       const y = getItemMapY(name);
+       return {
+               backgroundImage: `url(${ITEM_MAP_URL})`,
+               backgroundPosition: `-${x * 100}% -${y * 100}%`,
+               backgroundSize: `${ITEM_MAP_WIDTH * 100}% ${ITEM_MAP_HEIGHT * 100}%`,
+       };
+};
+
+const getIconURL = name => {
+       switch (name) {
+               case 'dungeon-ct':
+               case 'dungeon-dp':
+               case 'dungeon-ep':
+               case 'dungeon-gt':
+               case 'dungeon-hc':
+               case 'dungeon-ip':
+               case 'dungeon-mm':
+               case 'dungeon-pd':
+               case 'dungeon-sp':
+               case 'dungeon-sw':
+               case 'dungeon-th':
+               case 'dungeon-tr':
+               case 'dungeon-tt':
+                       return `/dungeon/${name.substr(8)}.png`;
+               default:
+                       return '';
+       }
+};
+
+const isHalfWidth = name => [
+       'blue-boomerang',
+       'fire-rod',
+       'ice-rod',
+       'hookshot',
+       'red-boomerang',
+].includes(name);
+
+const ZeldaIcon = ({ name, svg, title }) => {
+       const { t } = useTranslation();
+
+       const invert = name.startsWith('not-');
+       const strippedName = invert ? name.substr(4) : name;
+       const src = getIconURL(strippedName);
+       const alt = t(`icon.zelda.${name}`);
+       const realTitle = title !== '' ? title || alt : null;
+
+       if (svg) {
+               const clipX = getItemMapX(strippedName);
+               const clipY = getItemMapY(strippedName);
+               const cropX = isHalfWidth(strippedName) ? 0.25 : 0.02;
+               const cropY = 0.02;
+               const cropW = 1 - (2 * cropX);
+               const cropH = 1 - (2 * cropY);
+               return <image
+                       href={isOnItemMap(strippedName) ? ITEM_MAP_URL : src}
+                       width={ITEM_MAP_WIDTH}
+                       height={ITEM_MAP_HEIGHT}
+                       x={`-${clipX + 0.5}`}
+                       y={`-${clipY + 0.5}`}
+                       clipPath={`xywh(${clipX + cropX} ${clipY + cropY} ${cropW} ${cropH})`}
+               >
+                       {realTitle ?
+                               <title>{realTitle}</title>
+                       : null}
+               </image>;
+       }
+
+       return <span className="zelda-icon">
+               {isOnItemMap(strippedName) ?
+                       <span
+                               className="item-map-icon"
+                               style={getItemMapStyle(strippedName)}
+                               title={realTitle}
+                       />
+               : null}
+               {src ?
+                       <img
+                               alt={alt}
+                               src={src}
+                               title={realTitle}
+                       />
+               : null}
+               {invert ?
+                       <span className="strike">
+                               <Icon.SLASH title="" />
+                       </span>
+               : null}
+       </span>;
+};
+
+ZeldaIcon.propTypes = {
+       name: PropTypes.string.isRequired,
+       svg: PropTypes.bool,
+       title: PropTypes.string,
+};
+
+export default ZeldaIcon;
diff --git a/resources/js/components/discord-bot/Controls.js b/resources/js/components/discord-bot/Controls.js
deleted file mode 100644 (file)
index 408e314..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import { Col, Form, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import DiscordChannelSelect from '../common/DiscordChannelSelect';
-import DiscordSelect from '../common/DiscordSelect';
-
-const Controls = () => {
-       const [channel, setChannel] = React.useState('');
-       const [guild, setGuild] = React.useState(null);
-
-       const { t } = useTranslation();
-
-       return <>
-               <Row>
-                       <Form.Group as={Col} md={6}>
-                               <Form.Label>{t('discordBot.guild')}</Form.Label>
-                               <Form.Control
-                                       as={DiscordSelect}
-                                       onChange={({ guild }) => { setGuild(guild); setChannel(''); }}
-                                       value={guild ? guild.guild_id : ''}
-                               />
-                       </Form.Group>
-                       <Form.Group as={Col} md={6}>
-                               <Form.Label>{t('discordBot.channel')}</Form.Label>
-                               {guild ?
-                                       <Form.Control
-                                               as={DiscordChannelSelect}
-                                               guild={guild.guild_id}
-                                               onChange={({ target: { value } }) => setChannel(value)}
-                                               types={[]}
-                                               value={channel}
-                                       />
-                               :
-                                       <Form.Control plaintext readOnly defaultValue={t('discordBot.selectGuild')} />
-                               }
-                       </Form.Group>
-               </Row>
-       </>;
-};
-
-export default Controls;
diff --git a/resources/js/components/discord-bot/Controls.jsx b/resources/js/components/discord-bot/Controls.jsx
new file mode 100644 (file)
index 0000000..408e314
--- /dev/null
@@ -0,0 +1,42 @@
+import React from 'react';
+import { Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import DiscordChannelSelect from '../common/DiscordChannelSelect';
+import DiscordSelect from '../common/DiscordSelect';
+
+const Controls = () => {
+       const [channel, setChannel] = React.useState('');
+       const [guild, setGuild] = React.useState(null);
+
+       const { t } = useTranslation();
+
+       return <>
+               <Row>
+                       <Form.Group as={Col} md={6}>
+                               <Form.Label>{t('discordBot.guild')}</Form.Label>
+                               <Form.Control
+                                       as={DiscordSelect}
+                                       onChange={({ guild }) => { setGuild(guild); setChannel(''); }}
+                                       value={guild ? guild.guild_id : ''}
+                               />
+                       </Form.Group>
+                       <Form.Group as={Col} md={6}>
+                               <Form.Label>{t('discordBot.channel')}</Form.Label>
+                               {guild ?
+                                       <Form.Control
+                                               as={DiscordChannelSelect}
+                                               guild={guild.guild_id}
+                                               onChange={({ target: { value } }) => setChannel(value)}
+                                               types={[]}
+                                               value={channel}
+                                       />
+                               :
+                                       <Form.Control plaintext readOnly defaultValue={t('discordBot.selectGuild')} />
+                               }
+                       </Form.Group>
+               </Row>
+       </>;
+};
+
+export default Controls;
diff --git a/resources/js/components/discord-guilds/Box.js b/resources/js/components/discord-guilds/Box.js
deleted file mode 100644 (file)
index dcfff9e..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-const getIconUrl = guild =>
-       `https://cdn.discordapp.com/icons/${guild.guild_id}/${guild.icon_hash}.png`;
-
-const Box = ({ guild }) => <div className="guild-box">
-               <img alt="" src={getIconUrl(guild)} />
-               <span>{guild.name}</span>
-       </div>;
-
-Box.propTypes = {
-       guild: PropTypes.shape({
-               guild_id: PropTypes.string,
-               icon_hash: PropTypes.string,
-               name: PropTypes.string,
-       }),
-};
-
-export default Box;
diff --git a/resources/js/components/discord-guilds/Box.jsx b/resources/js/components/discord-guilds/Box.jsx
new file mode 100644 (file)
index 0000000..dcfff9e
--- /dev/null
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const getIconUrl = guild =>
+       `https://cdn.discordapp.com/icons/${guild.guild_id}/${guild.icon_hash}.png`;
+
+const Box = ({ guild }) => <div className="guild-box">
+               <img alt="" src={getIconUrl(guild)} />
+               <span>{guild.name}</span>
+       </div>;
+
+Box.propTypes = {
+       guild: PropTypes.shape({
+               guild_id: PropTypes.string,
+               icon_hash: PropTypes.string,
+               name: PropTypes.string,
+       }),
+};
+
+export default Box;
diff --git a/resources/js/components/discord-guilds/ChannelBox.js b/resources/js/components/discord-guilds/ChannelBox.js
deleted file mode 100644 (file)
index 9b75fad..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Icon from '../common/Icon';
-
-const getIcon = channel => {
-       if (channel.type === 0) {
-               return <Icon.HASH title="" />;
-       }
-       if (channel.type === 2) {
-               return <Icon.VOLUME title="" />;
-       }
-       return null;
-};
-
-const Box = ({ channel }) => <div className="channel-box">
-       {getIcon(channel)}
-       <span>{channel.name}</span>
-</div>;
-
-Box.propTypes = {
-       channel: PropTypes.shape({
-               name: PropTypes.string,
-       }),
-};
-
-export default Box;
diff --git a/resources/js/components/discord-guilds/ChannelBox.jsx b/resources/js/components/discord-guilds/ChannelBox.jsx
new file mode 100644 (file)
index 0000000..9b75fad
--- /dev/null
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Icon from '../common/Icon';
+
+const getIcon = channel => {
+       if (channel.type === 0) {
+               return <Icon.HASH title="" />;
+       }
+       if (channel.type === 2) {
+               return <Icon.VOLUME title="" />;
+       }
+       return null;
+};
+
+const Box = ({ channel }) => <div className="channel-box">
+       {getIcon(channel)}
+       <span>{channel.name}</span>
+</div>;
+
+Box.propTypes = {
+       channel: PropTypes.shape({
+               name: PropTypes.string,
+       }),
+};
+
+export default Box;
diff --git a/resources/js/components/episodes/ApplyDialog.js b/resources/js/components/episodes/ApplyDialog.js
deleted file mode 100644 (file)
index 5f0422e..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import ApplyForm from './ApplyForm';
-
-const ApplyDialog = ({
-       as,
-       episode,
-       onHide,
-       onSubmit,
-       show,
-}) => {
-       const { t } = useTranslation();
-
-       return <Modal className="apply-dialog" onHide={onHide} show={show}>
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('episodes.applyDialog.title')}
-                       </Modal.Title>
-               </Modal.Header>
-               <ApplyForm
-                       as={as}
-                       episode={episode}
-                       onCancel={onHide}
-                       onSubmit={onSubmit}
-               />
-       </Modal>;
-};
-
-ApplyDialog.propTypes = {
-       as: PropTypes.string,
-       episode: PropTypes.shape({
-       }),
-       onHide: PropTypes.func,
-       onSubmit: PropTypes.func,
-       show: PropTypes.bool,
-};
-
-export default ApplyDialog;
diff --git a/resources/js/components/episodes/ApplyDialog.jsx b/resources/js/components/episodes/ApplyDialog.jsx
new file mode 100644 (file)
index 0000000..5f0422e
--- /dev/null
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import ApplyForm from './ApplyForm';
+
+const ApplyDialog = ({
+       as,
+       episode,
+       onHide,
+       onSubmit,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal className="apply-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('episodes.applyDialog.title')}
+                       </Modal.Title>
+               </Modal.Header>
+               <ApplyForm
+                       as={as}
+                       episode={episode}
+                       onCancel={onHide}
+                       onSubmit={onSubmit}
+               />
+       </Modal>;
+};
+
+ApplyDialog.propTypes = {
+       as: PropTypes.string,
+       episode: PropTypes.shape({
+       }),
+       onHide: PropTypes.func,
+       onSubmit: PropTypes.func,
+       show: PropTypes.bool,
+};
+
+export default ApplyDialog;
diff --git a/resources/js/components/episodes/ApplyForm.js b/resources/js/components/episodes/ApplyForm.js
deleted file mode 100644 (file)
index ce5f88e..0000000
+++ /dev/null
@@ -1,120 +0,0 @@
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Form, Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import DialogEpisode from './DialogEpisode';
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import { applicableChannels } from '../../helpers/permissions';
-import { withUser } from '../../hooks/user';
-
-const ApplyForm = ({
-       as,
-       episode,
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       touched,
-       user,
-       values,
-}) => {
-       const { t } = useTranslation();
-
-       const available_channels = React.useMemo(() => {
-               return applicableChannels(user, episode, as);
-       }, [as, episode, user]);
-
-       return <Form noValidate onSubmit={handleSubmit}>
-               <Modal.Body>
-                       <DialogEpisode episode={episode} />
-                       <Form.Group controlId="apply.role">
-                               <Form.Label>{t('episodes.applyDialog.signUpAs')}</Form.Label>
-                               <Form.Control
-                                       plaintext
-                                       readOnly
-                                       value={t(`crew.roles.${as}`)}
-                               />
-                       </Form.Group>
-                       <Form.Group controlId="apply.channel_id">
-                               <Form.Label>{t('episodes.channel')}</Form.Label>
-                               <Form.Select
-                                       isInvalid={!!(touched.channel_id && errors.channel_id)}
-                                       name="channel_id"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.channel_id || 0}
-                               >
-                                       <option disabled value={0}>{t('general.pleaseSelect')}</option>
-                                       {available_channels.map(c =>
-                                               <option key={c.id} value={c.id}>
-                                                       {c.title}
-                                               </option>
-                                       )}
-                               </Form.Select>
-                               {touched.channel_id && errors.channel_id ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.channel_id)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Modal.Body>
-               <Modal.Footer>
-                       {onCancel ?
-                               <Button onClick={onCancel} variant="secondary">
-                                       {t('button.cancel')}
-                               </Button>
-                       : null}
-                       <Button type="submit" variant="primary">
-                               {t('button.submit')}
-                       </Button>
-               </Modal.Footer>
-       </Form>;
-};
-
-ApplyForm.propTypes = {
-       as: PropTypes.string,
-       episode: PropTypes.shape({
-       }),
-       errors: PropTypes.shape({
-               channel_id: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               channel_id: PropTypes.bool,
-       }),
-       user: PropTypes.shape({
-       }),
-       values: PropTypes.shape({
-               channel_id: PropTypes.number,
-       }),
-};
-
-export default withUser(withFormik({
-       displayName: 'ApplyForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { setErrors } = actions;
-               const { onSubmit } = actions.props;
-               try {
-                       await onSubmit(values);
-               } catch (e) {
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ as, episode, user }) => {
-               const channels = applicableChannels(user, episode, as);
-               return {
-                       as,
-                       channel_id: channels.length ? channels[0].id : 0,
-                       episode_id: episode ? episode.id : 0,
-               };
-       },
-})(ApplyForm));
diff --git a/resources/js/components/episodes/ApplyForm.jsx b/resources/js/components/episodes/ApplyForm.jsx
new file mode 100644 (file)
index 0000000..ce5f88e
--- /dev/null
@@ -0,0 +1,120 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import DialogEpisode from './DialogEpisode';
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import { applicableChannels } from '../../helpers/permissions';
+import { withUser } from '../../hooks/user';
+
+const ApplyForm = ({
+       as,
+       episode,
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       user,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       const available_channels = React.useMemo(() => {
+               return applicableChannels(user, episode, as);
+       }, [as, episode, user]);
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <Modal.Body>
+                       <DialogEpisode episode={episode} />
+                       <Form.Group controlId="apply.role">
+                               <Form.Label>{t('episodes.applyDialog.signUpAs')}</Form.Label>
+                               <Form.Control
+                                       plaintext
+                                       readOnly
+                                       value={t(`crew.roles.${as}`)}
+                               />
+                       </Form.Group>
+                       <Form.Group controlId="apply.channel_id">
+                               <Form.Label>{t('episodes.channel')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.channel_id && errors.channel_id)}
+                                       name="channel_id"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.channel_id || 0}
+                               >
+                                       <option disabled value={0}>{t('general.pleaseSelect')}</option>
+                                       {available_channels.map(c =>
+                                               <option key={c.id} value={c.id}>
+                                                       {c.title}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.channel_id && errors.channel_id ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.channel_id)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Modal.Body>
+               <Modal.Footer>
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.cancel')}
+                               </Button>
+                       : null}
+                       <Button type="submit" variant="primary">
+                               {t('button.submit')}
+                       </Button>
+               </Modal.Footer>
+       </Form>;
+};
+
+ApplyForm.propTypes = {
+       as: PropTypes.string,
+       episode: PropTypes.shape({
+       }),
+       errors: PropTypes.shape({
+               channel_id: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               channel_id: PropTypes.bool,
+       }),
+       user: PropTypes.shape({
+       }),
+       values: PropTypes.shape({
+               channel_id: PropTypes.number,
+       }),
+};
+
+export default withUser(withFormik({
+       displayName: 'ApplyForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { setErrors } = actions;
+               const { onSubmit } = actions.props;
+               try {
+                       await onSubmit(values);
+               } catch (e) {
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ as, episode, user }) => {
+               const channels = applicableChannels(user, episode, as);
+               return {
+                       as,
+                       channel_id: channels.length ? channels[0].id : 0,
+                       episode_id: episode ? episode.id : 0,
+               };
+       },
+})(ApplyForm));
diff --git a/resources/js/components/episodes/Channel.js b/resources/js/components/episodes/Channel.js
deleted file mode 100644 (file)
index 24569be..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-
-import Link from '../channel/Link';
-import Icon from '../common/Icon';
-import { mayEditRestream } from '../../helpers/permissions';
-import { useUser } from '../../hooks/user';
-
-const Channel = ({ channel, episode, onEditRestream }) => {
-       const { user } = useUser();
-
-       return <div className="episode-channel text-nowrap">
-               <Link channel={channel} />
-               {onEditRestream && mayEditRestream(user, episode, channel) ?
-                       <Button
-                               className="ms-1"
-                               onClick={() => onEditRestream(episode, channel)}
-                               variant="outline-secondary"
-                       >
-                               <Icon.SETTINGS />
-                       </Button>
-               : null}
-       </div>;
-};
-
-Channel.propTypes = {
-       channel: PropTypes.shape({
-               short_name: PropTypes.string,
-               stream_link: PropTypes.string,
-               title: PropTypes.string,
-       }),
-       episode: PropTypes.shape({
-       }),
-       onEditRestream: PropTypes.func,
-};
-
-export default Channel;
diff --git a/resources/js/components/episodes/Channel.jsx b/resources/js/components/episodes/Channel.jsx
new file mode 100644 (file)
index 0000000..24569be
--- /dev/null
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import Link from '../channel/Link';
+import Icon from '../common/Icon';
+import { mayEditRestream } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const Channel = ({ channel, episode, onEditRestream }) => {
+       const { user } = useUser();
+
+       return <div className="episode-channel text-nowrap">
+               <Link channel={channel} />
+               {onEditRestream && mayEditRestream(user, episode, channel) ?
+                       <Button
+                               className="ms-1"
+                               onClick={() => onEditRestream(episode, channel)}
+                               variant="outline-secondary"
+                       >
+                               <Icon.SETTINGS />
+                       </Button>
+               : null}
+       </div>;
+};
+
+Channel.propTypes = {
+       channel: PropTypes.shape({
+               short_name: PropTypes.string,
+               stream_link: PropTypes.string,
+               title: PropTypes.string,
+       }),
+       episode: PropTypes.shape({
+       }),
+       onEditRestream: PropTypes.func,
+};
+
+export default Channel;
diff --git a/resources/js/components/episodes/Channels.js b/resources/js/components/episodes/Channels.js
deleted file mode 100644 (file)
index f3fbf05..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Channel from './Channel';
-
-const Channels = ({ channels, episode, onEditRestream }) =>
-       channels.map(channel =>
-               <Channel
-                       channel={channel}
-                       episode={episode}
-                       key={channel.id}
-                       onEditRestream={onEditRestream}
-               />
-       );
-
-Channels.propTypes = {
-       channels: PropTypes.arrayOf(PropTypes.shape({
-       })),
-       episode: PropTypes.shape({
-       }),
-       onEditRestream: PropTypes.func,
-};
-
-export default Channels;
diff --git a/resources/js/components/episodes/Channels.jsx b/resources/js/components/episodes/Channels.jsx
new file mode 100644 (file)
index 0000000..f3fbf05
--- /dev/null
@@ -0,0 +1,24 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Channel from './Channel';
+
+const Channels = ({ channels, episode, onEditRestream }) =>
+       channels.map(channel =>
+               <Channel
+                       channel={channel}
+                       episode={episode}
+                       key={channel.id}
+                       onEditRestream={onEditRestream}
+               />
+       );
+
+Channels.propTypes = {
+       channels: PropTypes.arrayOf(PropTypes.shape({
+       })),
+       episode: PropTypes.shape({
+       }),
+       onEditRestream: PropTypes.func,
+};
+
+export default Channels;
diff --git a/resources/js/components/episodes/Crew.js b/resources/js/components/episodes/Crew.js
deleted file mode 100644 (file)
index d069841..0000000
+++ /dev/null
@@ -1,146 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import CrewMember from './CrewMember';
-import Icon from '../common/Icon';
-import { compareCrew } from '../../helpers/Crew';
-import {
-       getSGLanguages,
-       getSGSignupLink,
-       hasPassed,
-       hasSGRestream,
-} from '../../helpers/Episode';
-import { canApplyForEpisode } from '../../helpers/permissions';
-import { useUser } from '../../hooks/user';
-
-const Crew = ({ episode, onApply }) => {
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       const commentators = React.useMemo(() =>
-               episode.crew.filter(c => c.role === 'commentary').sort(compareCrew)
-       , [episode]);
-       const trackers = React.useMemo(() =>
-               episode.crew.filter(c => c.role === 'tracking').sort(compareCrew)
-       , [episode]);
-       const techies = React.useMemo(() =>
-               episode.crew.filter(c => c.role === 'setup').sort(compareCrew)
-       , [episode]);
-
-       const sgLanguages = React.useMemo(() =>
-               getSGLanguages(episode)
-       , [episode]);
-
-       const showCommentators = React.useMemo(() =>
-               commentators.length || (!hasPassed(episode) && (
-                       canApplyForEpisode(user, episode, 'commentary') ||
-                       hasSGRestream(episode)
-               ))
-       , [commentators, episode, user]);
-
-       const showTracker = React.useMemo(() =>
-               trackers.length || (!hasPassed(episode) && (
-                       canApplyForEpisode(user, episode, 'tracking') ||
-                       hasSGRestream(episode)
-               ))
-       , [episode, trackers, user]);
-
-       return <Row className="episode-crew">
-               {showCommentators ?
-                       <Col xs={6} md>
-                               <div className="fs-6 fs-md-5">
-                                       <Icon.MICROPHONE className="ms-3 me-2" title="" />
-                                       {t('episodes.commentary')}
-                               </div>
-                               {commentators.map(c =>
-                                       <CrewMember crew={c} key={c.id} />
-                               )}
-                               {onApply && canApplyForEpisode(user, episode, 'commentary') ?
-                                       <div className="button-bar m-2">
-                                               <Button
-                                                       onClick={() => onApply(episode, 'commentary')}
-                                                       variant="outline-secondary"
-                                               >
-                                                       {t('button.signUp')}
-                                               </Button>
-                                       </div>
-                               : null}
-                               {hasSGRestream(episode) ?
-                                       <div className="button-bar m-2">
-                                               {sgLanguages.map(lang =>
-                                                       <Button
-                                                               href={getSGSignupLink(episode, lang, 'commentator')}
-                                                               key={lang}
-                                                               target="_blank"
-                                                               variant="outline-secondary"
-                                                       >
-                                                               {`${t('episodes.sgSignUp')} ${lang.toUpperCase()}`}
-                                                       </Button>
-                                               )}
-                                       </div>
-                               : null}
-                       </Col>
-               : null}
-               {showTracker ?
-                       <Col xs={6} md>
-                               <div className="fs-6 fs-md-5">
-                                       <Icon.MOUSE className="ms-3 me-2" title="" />
-                                       {t('episodes.tracking')}
-                               </div>
-                               {trackers.map(c =>
-                                       <CrewMember crew={c} key={c.id} />
-                               )}
-                               {onApply && canApplyForEpisode(user, episode, 'tracking') ?
-                                       <div className="button-bar m-2">
-                                               <Button
-                                                       onClick={() => onApply(episode, 'tracking')}
-                                                       variant="outline-secondary"
-                                               >
-                                                       {t('button.signUp')}
-                                               </Button>
-                                       </div>
-                               : null}
-                               {hasSGRestream(episode) ?
-                                       <div className="button-bar m-2">
-                                               {sgLanguages.map(lang =>
-                                                       <Button
-                                                               href={getSGSignupLink(episode, lang, 'tracker')}
-                                                               key={lang}
-                                                               target="_blank"
-                                                               variant="outline-secondary"
-                                                       >
-                                                               {`${t('episodes.sgSignUp')} ${lang.toUpperCase()}`}
-                                                       </Button>
-                                               )}
-                                       </div>
-                               : null}
-                       </Col>
-               : null}
-               {techies.length ?
-                       <Col xs={6} md>
-                               <div className="fs-6 fs-md-5">
-                                       <Icon.MONITOR className="ms-3 me-2" title="" />
-                                       {t('episodes.setup')}
-                               </div>
-                               {techies.map(c =>
-                                       <CrewMember crew={c} key={c.id} />
-                               )}
-                       </Col>
-               : null}
-       </Row>;
-};
-
-Crew.propTypes = {
-       episode: PropTypes.shape({
-               channels: PropTypes.arrayOf(PropTypes.shape({
-                       id: PropTypes.number,
-               })),
-               crew: PropTypes.arrayOf(PropTypes.shape({
-               })),
-       }),
-       onApply: PropTypes.func,
-};
-
-export default Crew;
diff --git a/resources/js/components/episodes/Crew.jsx b/resources/js/components/episodes/Crew.jsx
new file mode 100644 (file)
index 0000000..d069841
--- /dev/null
@@ -0,0 +1,146 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import CrewMember from './CrewMember';
+import Icon from '../common/Icon';
+import { compareCrew } from '../../helpers/Crew';
+import {
+       getSGLanguages,
+       getSGSignupLink,
+       hasPassed,
+       hasSGRestream,
+} from '../../helpers/Episode';
+import { canApplyForEpisode } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const Crew = ({ episode, onApply }) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       const commentators = React.useMemo(() =>
+               episode.crew.filter(c => c.role === 'commentary').sort(compareCrew)
+       , [episode]);
+       const trackers = React.useMemo(() =>
+               episode.crew.filter(c => c.role === 'tracking').sort(compareCrew)
+       , [episode]);
+       const techies = React.useMemo(() =>
+               episode.crew.filter(c => c.role === 'setup').sort(compareCrew)
+       , [episode]);
+
+       const sgLanguages = React.useMemo(() =>
+               getSGLanguages(episode)
+       , [episode]);
+
+       const showCommentators = React.useMemo(() =>
+               commentators.length || (!hasPassed(episode) && (
+                       canApplyForEpisode(user, episode, 'commentary') ||
+                       hasSGRestream(episode)
+               ))
+       , [commentators, episode, user]);
+
+       const showTracker = React.useMemo(() =>
+               trackers.length || (!hasPassed(episode) && (
+                       canApplyForEpisode(user, episode, 'tracking') ||
+                       hasSGRestream(episode)
+               ))
+       , [episode, trackers, user]);
+
+       return <Row className="episode-crew">
+               {showCommentators ?
+                       <Col xs={6} md>
+                               <div className="fs-6 fs-md-5">
+                                       <Icon.MICROPHONE className="ms-3 me-2" title="" />
+                                       {t('episodes.commentary')}
+                               </div>
+                               {commentators.map(c =>
+                                       <CrewMember crew={c} key={c.id} />
+                               )}
+                               {onApply && canApplyForEpisode(user, episode, 'commentary') ?
+                                       <div className="button-bar m-2">
+                                               <Button
+                                                       onClick={() => onApply(episode, 'commentary')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       {t('button.signUp')}
+                                               </Button>
+                                       </div>
+                               : null}
+                               {hasSGRestream(episode) ?
+                                       <div className="button-bar m-2">
+                                               {sgLanguages.map(lang =>
+                                                       <Button
+                                                               href={getSGSignupLink(episode, lang, 'commentator')}
+                                                               key={lang}
+                                                               target="_blank"
+                                                               variant="outline-secondary"
+                                                       >
+                                                               {`${t('episodes.sgSignUp')} ${lang.toUpperCase()}`}
+                                                       </Button>
+                                               )}
+                                       </div>
+                               : null}
+                       </Col>
+               : null}
+               {showTracker ?
+                       <Col xs={6} md>
+                               <div className="fs-6 fs-md-5">
+                                       <Icon.MOUSE className="ms-3 me-2" title="" />
+                                       {t('episodes.tracking')}
+                               </div>
+                               {trackers.map(c =>
+                                       <CrewMember crew={c} key={c.id} />
+                               )}
+                               {onApply && canApplyForEpisode(user, episode, 'tracking') ?
+                                       <div className="button-bar m-2">
+                                               <Button
+                                                       onClick={() => onApply(episode, 'tracking')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       {t('button.signUp')}
+                                               </Button>
+                                       </div>
+                               : null}
+                               {hasSGRestream(episode) ?
+                                       <div className="button-bar m-2">
+                                               {sgLanguages.map(lang =>
+                                                       <Button
+                                                               href={getSGSignupLink(episode, lang, 'tracker')}
+                                                               key={lang}
+                                                               target="_blank"
+                                                               variant="outline-secondary"
+                                                       >
+                                                               {`${t('episodes.sgSignUp')} ${lang.toUpperCase()}`}
+                                                       </Button>
+                                               )}
+                                       </div>
+                               : null}
+                       </Col>
+               : null}
+               {techies.length ?
+                       <Col xs={6} md>
+                               <div className="fs-6 fs-md-5">
+                                       <Icon.MONITOR className="ms-3 me-2" title="" />
+                                       {t('episodes.setup')}
+                               </div>
+                               {techies.map(c =>
+                                       <CrewMember crew={c} key={c.id} />
+                               )}
+                       </Col>
+               : null}
+       </Row>;
+};
+
+Crew.propTypes = {
+       episode: PropTypes.shape({
+               channels: PropTypes.arrayOf(PropTypes.shape({
+                       id: PropTypes.number,
+               })),
+               crew: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+       onApply: PropTypes.func,
+};
+
+export default Crew;
diff --git a/resources/js/components/episodes/CrewManagement.js b/resources/js/components/episodes/CrewManagement.js
deleted file mode 100644 (file)
index 7c5d48e..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Form } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import CrewMember from './CrewMember';
-import Icon from '../common/Icon';
-import UserSelect from '../common/UserSelect';
-
-const CrewManagement = ({
-       channel,
-       episode,
-       manageCrew,
-       role,
-}) => {
-       const { t } = useTranslation();
-
-       const crews = React.useMemo(() =>
-               (episode.crew || [])
-               .filter(c => c.channel_id === channel.id && c.role === role)
-       , [channel, episode, role]);
-
-       const addCrew = React.useCallback(user_id => {
-               manageCrew({
-                       add: user_id,
-                       channel_id: channel.id,
-                       episode_id: episode.id,
-                       role,
-               });
-       }, [channel, episode, manageCrew, role]);
-
-       const confirmCrew = React.useCallback(crew => {
-               manageCrew({
-                       channel_id: channel.id,
-                       confirm: crew.id,
-                       episode_id: episode.id,
-                       role,
-               });
-       }, [channel, episode, manageCrew, role]);
-
-       const removeCrew = React.useCallback(crew => {
-               manageCrew({
-                       channel_id: channel.id,
-                       episode_id: episode.id,
-                       remove: crew.id,
-                       role,
-               });
-       }, [channel, episode, manageCrew, role]);
-
-       const unconfirmCrew = React.useCallback(crew => {
-               manageCrew({
-                       channel_id: channel.id,
-                       episode_id: episode.id,
-                       role,
-                       unconfirm: crew.id,
-               });
-       }, [channel, episode, manageCrew, role]);
-
-       return <div className="mt-2">
-               <div className="fs-4">{t(`crew.roles.${role}`)}</div>
-               {crews.map(crew =>
-                       <div className="d-flex align-items-center justify-content-between" key={crew.id}>
-                               <CrewMember crew={crew} />
-                               <div className="button-bar">
-                                       {crew.confirmed ?
-                                               <Button
-                                                       onClick={() => unconfirmCrew(crew)}
-                                                       title={t('button.unconfirm')}
-                                                       variant="outline-danger"
-                                               >
-                                                       <Icon.REJECT title="" />
-                                               </Button>
-                                       : null}
-                                       {!crew.confirmed ?
-                                               <Button
-                                                       onClick={() => confirmCrew(crew)}
-                                                       title={t('button.confirm')}
-                                                       variant="outline-success"
-                                               >
-                                                       <Icon.ACCEPT />
-                                               </Button>
-                                       : null}
-                                       <Button
-                                               onClick={() => removeCrew(crew)}
-                                               title={t('button.remove')}
-                                               variant="outline-danger"
-                                       >
-                                               <Icon.DELETE title="" />
-                                       </Button>
-                               </div>
-                       </div>
-               )}
-               <Form.Group controlId="crew.addUser">
-                       <Form.Label>{t('episodes.restreamDialog.addUser')}</Form.Label>
-                       <Form.Control
-                               as={UserSelect}
-                               onChange={e => addCrew(e.target.value)}
-                               value=""
-                       />
-               </Form.Group>
-       </div>;
-};
-
-CrewManagement.propTypes = {
-       channel: PropTypes.shape({
-               id: PropTypes.number,
-       }),
-       episode: PropTypes.shape({
-               crew: PropTypes.arrayOf(PropTypes.shape({
-                       channel_id: PropTypes.number,
-                       role: PropTypes.string,
-               })),
-               id: PropTypes.number,
-       }),
-       manageCrew: PropTypes.func,
-       role: PropTypes.string,
-};
-
-export default CrewManagement;
diff --git a/resources/js/components/episodes/CrewManagement.jsx b/resources/js/components/episodes/CrewManagement.jsx
new file mode 100644 (file)
index 0000000..7c5d48e
--- /dev/null
@@ -0,0 +1,119 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import CrewMember from './CrewMember';
+import Icon from '../common/Icon';
+import UserSelect from '../common/UserSelect';
+
+const CrewManagement = ({
+       channel,
+       episode,
+       manageCrew,
+       role,
+}) => {
+       const { t } = useTranslation();
+
+       const crews = React.useMemo(() =>
+               (episode.crew || [])
+               .filter(c => c.channel_id === channel.id && c.role === role)
+       , [channel, episode, role]);
+
+       const addCrew = React.useCallback(user_id => {
+               manageCrew({
+                       add: user_id,
+                       channel_id: channel.id,
+                       episode_id: episode.id,
+                       role,
+               });
+       }, [channel, episode, manageCrew, role]);
+
+       const confirmCrew = React.useCallback(crew => {
+               manageCrew({
+                       channel_id: channel.id,
+                       confirm: crew.id,
+                       episode_id: episode.id,
+                       role,
+               });
+       }, [channel, episode, manageCrew, role]);
+
+       const removeCrew = React.useCallback(crew => {
+               manageCrew({
+                       channel_id: channel.id,
+                       episode_id: episode.id,
+                       remove: crew.id,
+                       role,
+               });
+       }, [channel, episode, manageCrew, role]);
+
+       const unconfirmCrew = React.useCallback(crew => {
+               manageCrew({
+                       channel_id: channel.id,
+                       episode_id: episode.id,
+                       role,
+                       unconfirm: crew.id,
+               });
+       }, [channel, episode, manageCrew, role]);
+
+       return <div className="mt-2">
+               <div className="fs-4">{t(`crew.roles.${role}`)}</div>
+               {crews.map(crew =>
+                       <div className="d-flex align-items-center justify-content-between" key={crew.id}>
+                               <CrewMember crew={crew} />
+                               <div className="button-bar">
+                                       {crew.confirmed ?
+                                               <Button
+                                                       onClick={() => unconfirmCrew(crew)}
+                                                       title={t('button.unconfirm')}
+                                                       variant="outline-danger"
+                                               >
+                                                       <Icon.REJECT title="" />
+                                               </Button>
+                                       : null}
+                                       {!crew.confirmed ?
+                                               <Button
+                                                       onClick={() => confirmCrew(crew)}
+                                                       title={t('button.confirm')}
+                                                       variant="outline-success"
+                                               >
+                                                       <Icon.ACCEPT />
+                                               </Button>
+                                       : null}
+                                       <Button
+                                               onClick={() => removeCrew(crew)}
+                                               title={t('button.remove')}
+                                               variant="outline-danger"
+                                       >
+                                               <Icon.DELETE title="" />
+                                       </Button>
+                               </div>
+                       </div>
+               )}
+               <Form.Group controlId="crew.addUser">
+                       <Form.Label>{t('episodes.restreamDialog.addUser')}</Form.Label>
+                       <Form.Control
+                               as={UserSelect}
+                               onChange={e => addCrew(e.target.value)}
+                               value=""
+                       />
+               </Form.Group>
+       </div>;
+};
+
+CrewManagement.propTypes = {
+       channel: PropTypes.shape({
+               id: PropTypes.number,
+       }),
+       episode: PropTypes.shape({
+               crew: PropTypes.arrayOf(PropTypes.shape({
+                       channel_id: PropTypes.number,
+                       role: PropTypes.string,
+               })),
+               id: PropTypes.number,
+       }),
+       manageCrew: PropTypes.func,
+       role: PropTypes.string,
+};
+
+export default CrewManagement;
diff --git a/resources/js/components/episodes/CrewMember.js b/resources/js/components/episodes/CrewMember.js
deleted file mode 100644 (file)
index 96eb1b1..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-
-import { getName, getStreamLink } from '../../helpers/Crew';
-import { getAvatarUrl } from '../../helpers/User';
-
-const CrewMember = ({ crew }) => {
-       const classNames = [
-               'crew-member',
-               'text-light',
-       ];
-       if (!crew.confirmed) {
-               classNames.push('unconfirmed');
-       }
-       return <Button
-               className={classNames.join(' ')}
-               href={getStreamLink(crew) || null}
-               key={crew.id}
-               rel="noreferer"
-               variant="outline-twitch"
-       >
-               <img alt="" src={getAvatarUrl(crew.user)} />
-               <span>{getName(crew)}</span>
-       </Button>;
-};
-
-CrewMember.propTypes = {
-       crew: PropTypes.shape({
-               confirmed: PropTypes.bool,
-               id: PropTypes.number,
-               user: PropTypes.shape({
-               }),
-       }),
-};
-
-export default CrewMember;
diff --git a/resources/js/components/episodes/CrewMember.jsx b/resources/js/components/episodes/CrewMember.jsx
new file mode 100644 (file)
index 0000000..96eb1b1
--- /dev/null
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import { getName, getStreamLink } from '../../helpers/Crew';
+import { getAvatarUrl } from '../../helpers/User';
+
+const CrewMember = ({ crew }) => {
+       const classNames = [
+               'crew-member',
+               'text-light',
+       ];
+       if (!crew.confirmed) {
+               classNames.push('unconfirmed');
+       }
+       return <Button
+               className={classNames.join(' ')}
+               href={getStreamLink(crew) || null}
+               key={crew.id}
+               rel="noreferer"
+               variant="outline-twitch"
+       >
+               <img alt="" src={getAvatarUrl(crew.user)} />
+               <span>{getName(crew)}</span>
+       </Button>;
+};
+
+CrewMember.propTypes = {
+       crew: PropTypes.shape({
+               confirmed: PropTypes.bool,
+               id: PropTypes.number,
+               user: PropTypes.shape({
+               }),
+       }),
+};
+
+export default CrewMember;
diff --git a/resources/js/components/episodes/DialogEpisode.js b/resources/js/components/episodes/DialogEpisode.js
deleted file mode 100644 (file)
index c60b052..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import { getName } from '../../helpers/Crew';
-
-const DialogEpisode = ({ episode }) => {
-       const { t } = useTranslation();
-
-       if (!episode) return null;
-
-       return <>
-               <div>
-                       {episode.event.title}
-               </div>
-               <div>
-                       {t('episodes.startTime', { date: new Date(episode.start) })}
-               </div>
-               <div>
-                       {episode.players.map(p => getName(p)).join(', ')}
-               </div>
-       </>;
-};
-
-DialogEpisode.propTypes = {
-       episode: PropTypes.shape({
-               event: PropTypes.shape({
-                       title: PropTypes.string,
-               }),
-               players: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               start: PropTypes.string,
-       }),
-};
-
-export default DialogEpisode;
diff --git a/resources/js/components/episodes/DialogEpisode.jsx b/resources/js/components/episodes/DialogEpisode.jsx
new file mode 100644 (file)
index 0000000..c60b052
--- /dev/null
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { getName } from '../../helpers/Crew';
+
+const DialogEpisode = ({ episode }) => {
+       const { t } = useTranslation();
+
+       if (!episode) return null;
+
+       return <>
+               <div>
+                       {episode.event.title}
+               </div>
+               <div>
+                       {t('episodes.startTime', { date: new Date(episode.start) })}
+               </div>
+               <div>
+                       {episode.players.map(p => getName(p)).join(', ')}
+               </div>
+       </>;
+};
+
+DialogEpisode.propTypes = {
+       episode: PropTypes.shape({
+               event: PropTypes.shape({
+                       title: PropTypes.string,
+               }),
+               players: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               start: PropTypes.string,
+       }),
+};
+
+export default DialogEpisode;
diff --git a/resources/js/components/episodes/Filter.js b/resources/js/components/episodes/Filter.js
deleted file mode 100644 (file)
index 98d965d..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-
-import { isEventSelected, toggleEventFilter } from '../../helpers/Episode';
-
-const Filter = ({ events, filter, setFilter }) => {
-       const toggleEvent = React.useCallback(event => {
-               setFilter(toggleEventFilter(events, filter, event));
-       }, [events, filter, setFilter]);
-
-       if (!events || !events.length) return null;
-
-       return <div className="episode-filter button-bar text-end">
-               {events.map(event =>
-                       <Button
-                               active={isEventSelected(filter, event)}
-                               key={event.id}
-                               onClick={() => toggleEvent(event)}
-                               title={event.short ? event.title : null}
-                               variant="outline-secondary"
-                       >
-                               {event.short || event.title}
-                       </Button>
-               )}
-       </div>;
-};
-
-Filter.propTypes = {
-       events: PropTypes.arrayOf(PropTypes.shape({
-       })),
-       filter: PropTypes.shape(),
-       setFilter: PropTypes.func,
-};
-
-export default Filter;
diff --git a/resources/js/components/episodes/Filter.jsx b/resources/js/components/episodes/Filter.jsx
new file mode 100644 (file)
index 0000000..98d965d
--- /dev/null
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import { isEventSelected, toggleEventFilter } from '../../helpers/Episode';
+
+const Filter = ({ events, filter, setFilter }) => {
+       const toggleEvent = React.useCallback(event => {
+               setFilter(toggleEventFilter(events, filter, event));
+       }, [events, filter, setFilter]);
+
+       if (!events || !events.length) return null;
+
+       return <div className="episode-filter button-bar text-end">
+               {events.map(event =>
+                       <Button
+                               active={isEventSelected(filter, event)}
+                               key={event.id}
+                               onClick={() => toggleEvent(event)}
+                               title={event.short ? event.title : null}
+                               variant="outline-secondary"
+                       >
+                               {event.short || event.title}
+                       </Button>
+               )}
+       </div>;
+};
+
+Filter.propTypes = {
+       events: PropTypes.arrayOf(PropTypes.shape({
+       })),
+       filter: PropTypes.shape(),
+       setFilter: PropTypes.func,
+};
+
+export default Filter;
diff --git a/resources/js/components/episodes/Item.js b/resources/js/components/episodes/Item.js
deleted file mode 100644 (file)
index 612c5ea..0000000
+++ /dev/null
@@ -1,151 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-import { Link } from 'react-router-dom';
-
-import Channels from './Channels';
-import Crew from './Crew';
-import MultiLink from './MultiLink';
-import Players from './Players';
-import Icon from '../common/Icon';
-import { hasPassed, hasSGRestream, isActive } from '../../helpers/Episode';
-import { getLink } from '../../helpers/Event';
-import { canApplyForEpisode, canRestreamEpisode } from '../../helpers/permissions';
-import { useUser } from '../../hooks/user';
-
-const Item = ({ episode, onAddRestream, onApply, onEditRestream }) => {
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       const classNames = [
-               'episodes-item',
-               'my-3',
-               'p-2',
-               'border',
-               'rounded',
-       ];
-       if (isActive(episode)) {
-               classNames.push('is-active');
-       }
-
-       const style = React.useMemo(() => {
-               if (episode.event && episode.event.corner) {
-                       return {
-                               backgroundImage: `url(${episode.event.corner})`,
-                       };
-               }
-               return null;
-       }, [episode.event && episode.event.corner]);
-
-       const hasChannels = episode.channels && episode.channels.length;
-       const hasPlayers = episode.players && episode.players.length;
-
-       return <div className={classNames.join(' ')} style={style}>
-               <div className="d-flex align-items-stretch">
-                       <div className="episode-start me-3 fs-5 fs-md-4 text-end">
-                               {t('schedule.startTime', { date: new Date(episode.start) })}
-                       </div>
-                       <div className="episode-titlebar">
-                               {episode.title || episode.event ?
-                                       <div className="episode-title fs-5 fs-md-4">
-                                               {episode.title || episode.event.title}
-                                       </div>
-                               : null}
-                               {episode.comment ?
-                                       <div className="episode-comment">
-                                               {episode.comment}
-                                       </div>
-                               : null}
-                       </div>
-                       <div className="episode-channel-links ms-auto text-end">
-                               {hasChannels ?
-                                       <Channels
-                                               channels={episode.channels}
-                                               episode={episode}
-                                               onEditRestream={onEditRestream}
-                                       />
-                               : null}
-                               {!hasChannels && hasPlayers ?
-                                       <MultiLink players={episode.players} />
-                               : null}
-                               {episode.raceroom ?
-                                       <div>
-                                               <Button
-                                                       href={episode.raceroom}
-                                                       target="_blank"
-                                                       title={t('episodes.raceroom')}
-                                                       variant="outline-secondary"
-                                               >
-                                                       <Icon.RACETIME title="" />
-                                                       {' '}
-                                                       {t('episodes.raceroom')}
-                                               </Button>
-                                       </div>
-                               : null}
-                               {onAddRestream && canRestreamEpisode(user, episode) ?
-                                       <div>
-                                               <Button
-                                                       onClick={() => onAddRestream(episode)}
-                                                       title={t('episodes.addRestream')}
-                                                       variant="outline-secondary"
-                                               >
-                                                       <Icon.ADD title="" />
-                                               </Button>
-                                       </div>
-                               : null}
-                       </div>
-               </div>
-               <div className="episode-body d-flex flex-column flex-fill">
-                       {hasPlayers ?
-                               <Players players={episode.players} />
-                       : null}
-                       {(episode.crew && episode.crew.length) || (!hasPassed(episode) && (
-                                       hasSGRestream(episode)
-                                       || canApplyForEpisode(user, episode, 'commentary')
-                                       || canApplyForEpisode(user, episode, 'tracking')
-                       )) ?
-                               <div className="mb-3">
-                                       <Crew episode={episode} onApply={onApply} />
-                               </div>
-                       : null}
-                       {episode.event ?
-                               <div className="episode-event mt-auto">
-                                       {episode.event.description_id ?
-                                               <Link className="event-link" to={getLink(episode.event)}>
-                                                       {episode.event.title}
-                                               </Link>
-                                       :
-                                               episode.event.title
-                                       }
-                               </div>
-                       : null}
-               </div>
-       </div>;
-};
-
-Item.propTypes = {
-       episode: PropTypes.shape({
-               channels: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               comment: PropTypes.string,
-               crew: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               event: PropTypes.shape({
-                       corner: PropTypes.string,
-                       description_id: PropTypes.number,
-                       name: PropTypes.string,
-                       title: PropTypes.string,
-               }),
-               players: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               raceroom: PropTypes.string,
-               start: PropTypes.string,
-               title: PropTypes.string,
-       }),
-       onAddRestream: PropTypes.func,
-       onApply: PropTypes.func,
-       onEditRestream: PropTypes.func,
-};
-
-export default Item;
diff --git a/resources/js/components/episodes/Item.jsx b/resources/js/components/episodes/Item.jsx
new file mode 100644 (file)
index 0000000..612c5ea
--- /dev/null
@@ -0,0 +1,151 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+
+import Channels from './Channels';
+import Crew from './Crew';
+import MultiLink from './MultiLink';
+import Players from './Players';
+import Icon from '../common/Icon';
+import { hasPassed, hasSGRestream, isActive } from '../../helpers/Episode';
+import { getLink } from '../../helpers/Event';
+import { canApplyForEpisode, canRestreamEpisode } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const Item = ({ episode, onAddRestream, onApply, onEditRestream }) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       const classNames = [
+               'episodes-item',
+               'my-3',
+               'p-2',
+               'border',
+               'rounded',
+       ];
+       if (isActive(episode)) {
+               classNames.push('is-active');
+       }
+
+       const style = React.useMemo(() => {
+               if (episode.event && episode.event.corner) {
+                       return {
+                               backgroundImage: `url(${episode.event.corner})`,
+                       };
+               }
+               return null;
+       }, [episode.event && episode.event.corner]);
+
+       const hasChannels = episode.channels && episode.channels.length;
+       const hasPlayers = episode.players && episode.players.length;
+
+       return <div className={classNames.join(' ')} style={style}>
+               <div className="d-flex align-items-stretch">
+                       <div className="episode-start me-3 fs-5 fs-md-4 text-end">
+                               {t('schedule.startTime', { date: new Date(episode.start) })}
+                       </div>
+                       <div className="episode-titlebar">
+                               {episode.title || episode.event ?
+                                       <div className="episode-title fs-5 fs-md-4">
+                                               {episode.title || episode.event.title}
+                                       </div>
+                               : null}
+                               {episode.comment ?
+                                       <div className="episode-comment">
+                                               {episode.comment}
+                                       </div>
+                               : null}
+                       </div>
+                       <div className="episode-channel-links ms-auto text-end">
+                               {hasChannels ?
+                                       <Channels
+                                               channels={episode.channels}
+                                               episode={episode}
+                                               onEditRestream={onEditRestream}
+                                       />
+                               : null}
+                               {!hasChannels && hasPlayers ?
+                                       <MultiLink players={episode.players} />
+                               : null}
+                               {episode.raceroom ?
+                                       <div>
+                                               <Button
+                                                       href={episode.raceroom}
+                                                       target="_blank"
+                                                       title={t('episodes.raceroom')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       <Icon.RACETIME title="" />
+                                                       {' '}
+                                                       {t('episodes.raceroom')}
+                                               </Button>
+                                       </div>
+                               : null}
+                               {onAddRestream && canRestreamEpisode(user, episode) ?
+                                       <div>
+                                               <Button
+                                                       onClick={() => onAddRestream(episode)}
+                                                       title={t('episodes.addRestream')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       <Icon.ADD title="" />
+                                               </Button>
+                                       </div>
+                               : null}
+                       </div>
+               </div>
+               <div className="episode-body d-flex flex-column flex-fill">
+                       {hasPlayers ?
+                               <Players players={episode.players} />
+                       : null}
+                       {(episode.crew && episode.crew.length) || (!hasPassed(episode) && (
+                                       hasSGRestream(episode)
+                                       || canApplyForEpisode(user, episode, 'commentary')
+                                       || canApplyForEpisode(user, episode, 'tracking')
+                       )) ?
+                               <div className="mb-3">
+                                       <Crew episode={episode} onApply={onApply} />
+                               </div>
+                       : null}
+                       {episode.event ?
+                               <div className="episode-event mt-auto">
+                                       {episode.event.description_id ?
+                                               <Link className="event-link" to={getLink(episode.event)}>
+                                                       {episode.event.title}
+                                               </Link>
+                                       :
+                                               episode.event.title
+                                       }
+                               </div>
+                       : null}
+               </div>
+       </div>;
+};
+
+Item.propTypes = {
+       episode: PropTypes.shape({
+               channels: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               comment: PropTypes.string,
+               crew: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               event: PropTypes.shape({
+                       corner: PropTypes.string,
+                       description_id: PropTypes.number,
+                       name: PropTypes.string,
+                       title: PropTypes.string,
+               }),
+               players: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               raceroom: PropTypes.string,
+               start: PropTypes.string,
+               title: PropTypes.string,
+       }),
+       onAddRestream: PropTypes.func,
+       onApply: PropTypes.func,
+       onEditRestream: PropTypes.func,
+};
+
+export default Item;
diff --git a/resources/js/components/episodes/List.js b/resources/js/components/episodes/List.js
deleted file mode 100644 (file)
index a2a3b29..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Item from './Item';
-
-const List = ({ episodes, onAddRestream, onApply, onEditRestream }) => {
-       const grouped = React.useMemo(() => episodes.reduce((groups, episode) => {
-               const day = moment(episode.start).format('YYYY-MM-DD');
-               return {
-                       ...groups,
-                       [day]: [
-                               ...groups[day] || [],
-                               episode,
-                       ],
-               };
-       }, {}), [episodes]);
-
-       return <div className="episodes-list">
-               {Object.entries(grouped).map(([day, group]) => <div key={day}>
-                       <h2 className="text-center episodes-group-heading">
-                               {moment(day).format('dddd, L')}
-                       </h2>
-                       {group.map(episode =>
-                               <Item
-                                       episode={episode}
-                                       onAddRestream={onAddRestream}
-                                       onApply={onApply}
-                                       onEditRestream={onEditRestream}
-                                       key={episode.id}
-                               />
-                       )}
-               </div>)}
-       </div>;
-};
-
-List.propTypes = {
-       episodes: PropTypes.arrayOf(PropTypes.shape({
-               start: PropTypes.string,
-       })),
-       onAddRestream: PropTypes.func,
-       onApply: PropTypes.func,
-       onEditRestream: PropTypes.func,
-};
-
-export default List;
diff --git a/resources/js/components/episodes/List.jsx b/resources/js/components/episodes/List.jsx
new file mode 100644 (file)
index 0000000..a2a3b29
--- /dev/null
@@ -0,0 +1,46 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Item from './Item';
+
+const List = ({ episodes, onAddRestream, onApply, onEditRestream }) => {
+       const grouped = React.useMemo(() => episodes.reduce((groups, episode) => {
+               const day = moment(episode.start).format('YYYY-MM-DD');
+               return {
+                       ...groups,
+                       [day]: [
+                               ...groups[day] || [],
+                               episode,
+                       ],
+               };
+       }, {}), [episodes]);
+
+       return <div className="episodes-list">
+               {Object.entries(grouped).map(([day, group]) => <div key={day}>
+                       <h2 className="text-center episodes-group-heading">
+                               {moment(day).format('dddd, L')}
+                       </h2>
+                       {group.map(episode =>
+                               <Item
+                                       episode={episode}
+                                       onAddRestream={onAddRestream}
+                                       onApply={onApply}
+                                       onEditRestream={onEditRestream}
+                                       key={episode.id}
+                               />
+                       )}
+               </div>)}
+       </div>;
+};
+
+List.propTypes = {
+       episodes: PropTypes.arrayOf(PropTypes.shape({
+               start: PropTypes.string,
+       })),
+       onAddRestream: PropTypes.func,
+       onApply: PropTypes.func,
+       onEditRestream: PropTypes.func,
+};
+
+export default List;
diff --git a/resources/js/components/episodes/MultiLink.js b/resources/js/components/episodes/MultiLink.js
deleted file mode 100644 (file)
index 2f737dd..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-
-import Icon from '../common/Icon';
-import { getStreamLink } from '../../helpers/Crew';
-
-const MultiLink = ({ players }) => {
-       const streams = players.map(getStreamLink);
-       const names = streams.map(s => s.split('/').pop());
-       const url = `https://multistre.am/${names.join('/')}`;
-
-       return <div className="episode-channel">
-               <Button
-                       href={url}
-                       rel="noreferer"
-                       target="_blank"
-                       title="MultiTwitch"
-                       variant="outline-twitch"
-               >
-                       <Icon.STREAM />
-                       {' MultiStream'}
-               </Button>
-       </div>;
-};
-
-MultiLink.propTypes = {
-       players: PropTypes.arrayOf(PropTypes.shape({
-               short_name: PropTypes.string,
-               stream_link: PropTypes.string,
-               title: PropTypes.string,
-       })),
-};
-
-export default MultiLink;
diff --git a/resources/js/components/episodes/MultiLink.jsx b/resources/js/components/episodes/MultiLink.jsx
new file mode 100644 (file)
index 0000000..2f737dd
--- /dev/null
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import Icon from '../common/Icon';
+import { getStreamLink } from '../../helpers/Crew';
+
+const MultiLink = ({ players }) => {
+       const streams = players.map(getStreamLink);
+       const names = streams.map(s => s.split('/').pop());
+       const url = `https://multistre.am/${names.join('/')}`;
+
+       return <div className="episode-channel">
+               <Button
+                       href={url}
+                       rel="noreferer"
+                       target="_blank"
+                       title="MultiTwitch"
+                       variant="outline-twitch"
+               >
+                       <Icon.STREAM />
+                       {' MultiStream'}
+               </Button>
+       </div>;
+};
+
+MultiLink.propTypes = {
+       players: PropTypes.arrayOf(PropTypes.shape({
+               short_name: PropTypes.string,
+               stream_link: PropTypes.string,
+               title: PropTypes.string,
+       })),
+};
+
+export default MultiLink;
diff --git a/resources/js/components/episodes/Player.js b/resources/js/components/episodes/Player.js
deleted file mode 100644 (file)
index 3fc285f..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-
-import { getName, getStreamLink } from '../../helpers/Crew';
-import { getAvatarUrl } from '../../helpers/User';
-
-const Player = ({ player }) => {
-       return <div className="episode-player my-3">
-               <Button
-                       className="player-link"
-                       href={getStreamLink(player)}
-                       rel="noreferrer"
-                       target="_blank"
-                       variant="outline-twitch"
-               >
-                       <img alt="" src={getAvatarUrl(player.user)} />
-                       <span className="text-light fs-5 fs-md-4">{getName(player)}</span>
-               </Button>
-       </div>;
-};
-
-Player.propTypes = {
-       player: PropTypes.shape({
-               id: PropTypes.number,
-               name_override: PropTypes.string,
-               stream_override: PropTypes.string,
-               user: PropTypes.shape({
-               }),
-       }),
-};
-
-export default Player;
diff --git a/resources/js/components/episodes/Player.jsx b/resources/js/components/episodes/Player.jsx
new file mode 100644 (file)
index 0000000..3fc285f
--- /dev/null
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+
+import { getName, getStreamLink } from '../../helpers/Crew';
+import { getAvatarUrl } from '../../helpers/User';
+
+const Player = ({ player }) => {
+       return <div className="episode-player my-3">
+               <Button
+                       className="player-link"
+                       href={getStreamLink(player)}
+                       rel="noreferrer"
+                       target="_blank"
+                       variant="outline-twitch"
+               >
+                       <img alt="" src={getAvatarUrl(player.user)} />
+                       <span className="text-light fs-5 fs-md-4">{getName(player)}</span>
+               </Button>
+       </div>;
+};
+
+Player.propTypes = {
+       player: PropTypes.shape({
+               id: PropTypes.number,
+               name_override: PropTypes.string,
+               stream_override: PropTypes.string,
+               user: PropTypes.shape({
+               }),
+       }),
+};
+
+export default Player;
diff --git a/resources/js/components/episodes/Players.js b/resources/js/components/episodes/Players.js
deleted file mode 100644 (file)
index 3a3cf59..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Player from './Player';
-
-const Players = ({ players }) =>
-       <div className="episode-players">
-               {players.map(player =>
-                       <Player key={player.id} player={player} />
-               )}
-       </div>;
-
-Players.propTypes = {
-       players: PropTypes.arrayOf(PropTypes.shape({
-               id: PropTypes.number,
-       })),
-};
-
-export default Players;
diff --git a/resources/js/components/episodes/Players.jsx b/resources/js/components/episodes/Players.jsx
new file mode 100644 (file)
index 0000000..3a3cf59
--- /dev/null
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Player from './Player';
+
+const Players = ({ players }) =>
+       <div className="episode-players">
+               {players.map(player =>
+                       <Player key={player.id} player={player} />
+               )}
+       </div>;
+
+Players.propTypes = {
+       players: PropTypes.arrayOf(PropTypes.shape({
+               id: PropTypes.number,
+       })),
+};
+
+export default Players;
diff --git a/resources/js/components/episodes/RestreamAddForm.js b/resources/js/components/episodes/RestreamAddForm.js
deleted file mode 100644 (file)
index c09b92b..0000000
+++ /dev/null
@@ -1,159 +0,0 @@
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import DialogEpisode from './DialogEpisode';
-import ToggleSwitch from '../common/ToggleSwitch';
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import { withUser } from '../../hooks/user';
-
-const channelCompare = (a, b) => a.channel.title.localeCompare(b.channel.title);
-
-const RestreamAddForm = ({
-       episode,
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       touched,
-       user,
-       values,
-}) => {
-       const { t } = useTranslation();
-
-       return <Form noValidate onSubmit={handleSubmit}>
-               <Modal.Body>
-                       <DialogEpisode episode={episode} />
-                       <Form.Group controlId="episodes.channel_id">
-                               <Form.Label>{t('episodes.channel')}</Form.Label>
-                               <Form.Select
-                                       isInvalid={!!(touched.channel_id && errors.channel_id)}
-                                       name="channel_id"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.channel_id || 0}
-                               >
-                                       <option disabled value={0}>{t('general.pleaseSelect')}</option>
-                                       {((user && user.channel_crews) || []).sort(channelCompare).map(c =>
-                                               <option key={c.id} value={c.channel_id}>
-                                                       {c.channel.title}
-                                               </option>
-                                       )}
-                               </Form.Select>
-                               {touched.channel_id && errors.channel_id ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.channel_id)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Row>
-                               <Form.Group as={Col} sm={6} controlId="episodes.accept_comms">
-                                       <Form.Label className="d-block">
-                                               {t('episodes.restreamDialog.acceptComms')}
-                                       </Form.Label>
-                                       <Form.Control
-                                               as={ToggleSwitch}
-                                               isInvalid={!!(touched.accept_comms && errors.accept_comms)}
-                                               name="accept_comms"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               value={!!values.accept_comms}
-                                       />
-                                       {touched.accept_comms && errors.accept_comms ?
-                                               <Form.Control.Feedback type="invalid">
-                                                       {t(errors.accept_comms)}
-                                               </Form.Control.Feedback>
-                                       : null}
-                               </Form.Group>
-                               <Form.Group as={Col} sm={6} controlId="episodes.accept_tracker">
-                                       <Form.Label className="d-block">
-                                               {t('episodes.restreamDialog.acceptTracker')}
-                                       </Form.Label>
-                                       <Form.Control
-                                               as={ToggleSwitch}
-                                               isInvalid={!!(touched.accept_tracker && errors.accept_tracker)}
-                                               name="accept_tracker"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               value={!!values.accept_tracker}
-                                       />
-                                       {touched.accept_tracker && errors.accept_tracker ?
-                                               <Form.Control.Feedback type="invalid">
-                                                       {t(errors.accept_tracker)}
-                                               </Form.Control.Feedback>
-                                       : null}
-                               </Form.Group>
-                       </Row>
-               </Modal.Body>
-               <Modal.Footer>
-                       {onCancel ?
-                               <Button onClick={onCancel} variant="secondary">
-                                       {t('button.cancel')}
-                               </Button>
-                       : null}
-                       <Button type="submit" variant="primary">
-                               {t('button.save')}
-                       </Button>
-               </Modal.Footer>
-       </Form>;
-};
-
-RestreamAddForm.propTypes = {
-       episode: PropTypes.shape({
-               event: PropTypes.shape({
-                       title: PropTypes.string,
-               }),
-               players: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               start: PropTypes.string,
-       }),
-       errors: PropTypes.shape({
-               accept_comms: PropTypes.string,
-               accept_tracker: PropTypes.string,
-               channel_id: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               accept_comms: PropTypes.bool,
-               accept_tracker: PropTypes.bool,
-               channel_id: PropTypes.bool,
-       }),
-       user: PropTypes.shape({
-               channel_crews: PropTypes.arrayOf(PropTypes.shape({
-               })),
-       }),
-       values: PropTypes.shape({
-               accept_comms: PropTypes.bool,
-               accept_tracker: PropTypes.bool,
-               channel_id: PropTypes.number,
-       }),
-};
-
-export default withUser(withFormik({
-       displayName: 'RestreamAddForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { setErrors } = actions;
-               const { onSubmit } = actions.props;
-               try {
-                       await onSubmit(values);
-               } catch (e) {
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ episode, user }) => ({
-               accept_comms: false,
-               accept_tracker: false,
-               channel_id: user && user.channel_crews && user.channel_crews.length
-                       ? user.channel_crews[0].channel_id : 0,
-               episode_id: episode ? episode.id : 0,
-       }),
-})(RestreamAddForm));
diff --git a/resources/js/components/episodes/RestreamAddForm.jsx b/resources/js/components/episodes/RestreamAddForm.jsx
new file mode 100644 (file)
index 0000000..c09b92b
--- /dev/null
@@ -0,0 +1,159 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import DialogEpisode from './DialogEpisode';
+import ToggleSwitch from '../common/ToggleSwitch';
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import { withUser } from '../../hooks/user';
+
+const channelCompare = (a, b) => a.channel.title.localeCompare(b.channel.title);
+
+const RestreamAddForm = ({
+       episode,
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       user,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <Modal.Body>
+                       <DialogEpisode episode={episode} />
+                       <Form.Group controlId="episodes.channel_id">
+                               <Form.Label>{t('episodes.channel')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.channel_id && errors.channel_id)}
+                                       name="channel_id"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.channel_id || 0}
+                               >
+                                       <option disabled value={0}>{t('general.pleaseSelect')}</option>
+                                       {((user && user.channel_crews) || []).sort(channelCompare).map(c =>
+                                               <option key={c.id} value={c.channel_id}>
+                                                       {c.channel.title}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.channel_id && errors.channel_id ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.channel_id)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Row>
+                               <Form.Group as={Col} sm={6} controlId="episodes.accept_comms">
+                                       <Form.Label className="d-block">
+                                               {t('episodes.restreamDialog.acceptComms')}
+                                       </Form.Label>
+                                       <Form.Control
+                                               as={ToggleSwitch}
+                                               isInvalid={!!(touched.accept_comms && errors.accept_comms)}
+                                               name="accept_comms"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               value={!!values.accept_comms}
+                                       />
+                                       {touched.accept_comms && errors.accept_comms ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.accept_comms)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                               <Form.Group as={Col} sm={6} controlId="episodes.accept_tracker">
+                                       <Form.Label className="d-block">
+                                               {t('episodes.restreamDialog.acceptTracker')}
+                                       </Form.Label>
+                                       <Form.Control
+                                               as={ToggleSwitch}
+                                               isInvalid={!!(touched.accept_tracker && errors.accept_tracker)}
+                                               name="accept_tracker"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               value={!!values.accept_tracker}
+                                       />
+                                       {touched.accept_tracker && errors.accept_tracker ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.accept_tracker)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                       </Row>
+               </Modal.Body>
+               <Modal.Footer>
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.cancel')}
+                               </Button>
+                       : null}
+                       <Button type="submit" variant="primary">
+                               {t('button.save')}
+                       </Button>
+               </Modal.Footer>
+       </Form>;
+};
+
+RestreamAddForm.propTypes = {
+       episode: PropTypes.shape({
+               event: PropTypes.shape({
+                       title: PropTypes.string,
+               }),
+               players: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               start: PropTypes.string,
+       }),
+       errors: PropTypes.shape({
+               accept_comms: PropTypes.string,
+               accept_tracker: PropTypes.string,
+               channel_id: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               accept_comms: PropTypes.bool,
+               accept_tracker: PropTypes.bool,
+               channel_id: PropTypes.bool,
+       }),
+       user: PropTypes.shape({
+               channel_crews: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+       values: PropTypes.shape({
+               accept_comms: PropTypes.bool,
+               accept_tracker: PropTypes.bool,
+               channel_id: PropTypes.number,
+       }),
+};
+
+export default withUser(withFormik({
+       displayName: 'RestreamAddForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { setErrors } = actions;
+               const { onSubmit } = actions.props;
+               try {
+                       await onSubmit(values);
+               } catch (e) {
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ episode, user }) => ({
+               accept_comms: false,
+               accept_tracker: false,
+               channel_id: user && user.channel_crews && user.channel_crews.length
+                       ? user.channel_crews[0].channel_id : 0,
+               episode_id: episode ? episode.id : 0,
+       }),
+})(RestreamAddForm));
diff --git a/resources/js/components/episodes/RestreamDialog.js b/resources/js/components/episodes/RestreamDialog.js
deleted file mode 100644 (file)
index 2b7454b..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import RestreamAddForm from './RestreamAddForm';
-import RestreamEditForm from './RestreamEditForm';
-
-const RestreamDialog = ({
-       channel,
-       editRestream,
-       episode,
-       manageCrew,
-       onHide,
-       onRemoveRestream,
-       onSubmit,
-       show,
-}) => {
-       const { t } = useTranslation();
-
-       return <Modal className="restream-dialog" onHide={onHide} show={show}>
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('episodes.restreamDialog.title')}
-                       </Modal.Title>
-               </Modal.Header>
-               {channel ?
-                       <RestreamEditForm
-                               channel={channel}
-                               editRestream={editRestream}
-                               episode={episode}
-                               manageCrew={manageCrew}
-                               onCancel={onHide}
-                               onRemoveRestream={onRemoveRestream}
-                       />
-               :
-                       <RestreamAddForm
-                               episode={episode}
-                               onCancel={onHide}
-                               onSubmit={onSubmit}
-                       />
-               }
-       </Modal>;
-};
-
-RestreamDialog.propTypes = {
-       channel: PropTypes.shape({
-       }),
-       editRestream: PropTypes.func,
-       episode: PropTypes.shape({
-       }),
-       manageCrew: PropTypes.func,
-       onHide: PropTypes.func,
-       onRemoveRestream: PropTypes.func,
-       onSubmit: PropTypes.func,
-       show: PropTypes.bool,
-};
-
-export default RestreamDialog;
diff --git a/resources/js/components/episodes/RestreamDialog.jsx b/resources/js/components/episodes/RestreamDialog.jsx
new file mode 100644 (file)
index 0000000..2b7454b
--- /dev/null
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import RestreamAddForm from './RestreamAddForm';
+import RestreamEditForm from './RestreamEditForm';
+
+const RestreamDialog = ({
+       channel,
+       editRestream,
+       episode,
+       manageCrew,
+       onHide,
+       onRemoveRestream,
+       onSubmit,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal className="restream-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('episodes.restreamDialog.title')}
+                       </Modal.Title>
+               </Modal.Header>
+               {channel ?
+                       <RestreamEditForm
+                               channel={channel}
+                               editRestream={editRestream}
+                               episode={episode}
+                               manageCrew={manageCrew}
+                               onCancel={onHide}
+                               onRemoveRestream={onRemoveRestream}
+                       />
+               :
+                       <RestreamAddForm
+                               episode={episode}
+                               onCancel={onHide}
+                               onSubmit={onSubmit}
+                       />
+               }
+       </Modal>;
+};
+
+RestreamDialog.propTypes = {
+       channel: PropTypes.shape({
+       }),
+       editRestream: PropTypes.func,
+       episode: PropTypes.shape({
+       }),
+       manageCrew: PropTypes.func,
+       onHide: PropTypes.func,
+       onRemoveRestream: PropTypes.func,
+       onSubmit: PropTypes.func,
+       show: PropTypes.bool,
+};
+
+export default RestreamDialog;
diff --git a/resources/js/components/episodes/RestreamEditForm.js b/resources/js/components/episodes/RestreamEditForm.js
deleted file mode 100644 (file)
index 84a3ea2..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import CrewManagement from './CrewManagement';
-import ToggleSwitch from '../common/ToggleSwitch';
-import { getName } from '../../helpers/Crew';
-
-const RestreamEditForm = ({
-       channel,
-       editRestream,
-       episode,
-       manageCrew,
-       onCancel,
-       onRemoveRestream,
-}) => {
-       const { t } = useTranslation();
-
-       const acceptToggle = React.useCallback(e => {
-               editRestream({
-                       channel_id: channel.id,
-                       episode_id: episode.id,
-                       [e.target.name]: e.target.value,
-               });
-       }, [channel, editRestream, episode]);
-
-       return <>
-               <Modal.Body>
-                       {channel ?
-                               <div>
-                                       {channel.title}
-                               </div>
-                       : null}
-                       {episode ? <>
-                               <div>
-                                       {episode.event.title}
-                               </div>
-                               <div>
-                                       {t('episodes.startTime', { date: new Date(episode.start) })}
-                               </div>
-                               <div>
-                                       {episode.players.map(p => getName(p)).join(', ')}
-                               </div>
-                       </> : null}
-                       {channel && episode && editRestream ?
-                               <Row>
-                                       <Form.Group as={Col} sm={6} controlId="episodes.accept_comms">
-                                               <Form.Label className="d-block">
-                                                       {t('episodes.restreamDialog.acceptComms')}
-                                               </Form.Label>
-                                               <Form.Control
-                                                       as={ToggleSwitch}
-                                                       name="accept_comms"
-                                                       onChange={acceptToggle}
-                                                       value={!!channel.pivot.accept_comms}
-                                               />
-                                       </Form.Group>
-                                       <Form.Group as={Col} sm={6} controlId="episodes.accept_tracker">
-                                               <Form.Label className="d-block">
-                                                       {t('episodes.restreamDialog.acceptTracker')}
-                                               </Form.Label>
-                                               <Form.Control
-                                                       as={ToggleSwitch}
-                                                       name="accept_tracker"
-                                                       onChange={acceptToggle}
-                                                       value={!!channel.pivot.accept_tracker}
-                                               />
-                                       </Form.Group>
-                               </Row>
-                       : null}
-                       {channel && episode && manageCrew ? <>
-                               <CrewManagement
-                                       channel={channel}
-                                       episode={episode}
-                                       manageCrew={manageCrew}
-                                       role="commentary"
-                               />
-                               <CrewManagement
-                                       channel={channel}
-                                       episode={episode}
-                                       manageCrew={manageCrew}
-                                       role="tracking"
-                               />
-                               <CrewManagement
-                                       channel={channel}
-                                       episode={episode}
-                                       manageCrew={manageCrew}
-                                       role="setup"
-                               />
-                       </> : null}
-               </Modal.Body>
-               <Modal.Footer className="justify-content-between">
-                       {onRemoveRestream ?
-                               <Button onClick={() => onRemoveRestream(episode, channel)} variant="outline-danger">
-                                       {t('button.remove')}
-                               </Button>
-                       : null}
-                       {onCancel ?
-                               <Button onClick={onCancel} variant="secondary">
-                                       {t('button.close')}
-                               </Button>
-                       : null}
-               </Modal.Footer>
-       </>;
-};
-
-RestreamEditForm.propTypes = {
-       channel: PropTypes.shape({
-               id: PropTypes.number,
-               pivot: PropTypes.shape({
-                       accept_comms: PropTypes.bool,
-                       accept_tracker: PropTypes.bool,
-               }),
-               title: PropTypes.string,
-       }),
-       editRestream: PropTypes.func,
-       episode: PropTypes.shape({
-               crew: PropTypes.arrayOf(PropTypes.shape({
-                       channel_id: PropTypes.number,
-                       role: PropTypes.string,
-               })),
-               event: PropTypes.shape({
-                       title: PropTypes.string,
-               }),
-               id: PropTypes.number,
-               players: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               start: PropTypes.string,
-       }),
-       manageCrew: PropTypes.func,
-       onCancel: PropTypes.func,
-       onRemoveRestream: PropTypes.func,
-};
-
-export default RestreamEditForm;
diff --git a/resources/js/components/episodes/RestreamEditForm.jsx b/resources/js/components/episodes/RestreamEditForm.jsx
new file mode 100644 (file)
index 0000000..84a3ea2
--- /dev/null
@@ -0,0 +1,136 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import CrewManagement from './CrewManagement';
+import ToggleSwitch from '../common/ToggleSwitch';
+import { getName } from '../../helpers/Crew';
+
+const RestreamEditForm = ({
+       channel,
+       editRestream,
+       episode,
+       manageCrew,
+       onCancel,
+       onRemoveRestream,
+}) => {
+       const { t } = useTranslation();
+
+       const acceptToggle = React.useCallback(e => {
+               editRestream({
+                       channel_id: channel.id,
+                       episode_id: episode.id,
+                       [e.target.name]: e.target.value,
+               });
+       }, [channel, editRestream, episode]);
+
+       return <>
+               <Modal.Body>
+                       {channel ?
+                               <div>
+                                       {channel.title}
+                               </div>
+                       : null}
+                       {episode ? <>
+                               <div>
+                                       {episode.event.title}
+                               </div>
+                               <div>
+                                       {t('episodes.startTime', { date: new Date(episode.start) })}
+                               </div>
+                               <div>
+                                       {episode.players.map(p => getName(p)).join(', ')}
+                               </div>
+                       </> : null}
+                       {channel && episode && editRestream ?
+                               <Row>
+                                       <Form.Group as={Col} sm={6} controlId="episodes.accept_comms">
+                                               <Form.Label className="d-block">
+                                                       {t('episodes.restreamDialog.acceptComms')}
+                                               </Form.Label>
+                                               <Form.Control
+                                                       as={ToggleSwitch}
+                                                       name="accept_comms"
+                                                       onChange={acceptToggle}
+                                                       value={!!channel.pivot.accept_comms}
+                                               />
+                                       </Form.Group>
+                                       <Form.Group as={Col} sm={6} controlId="episodes.accept_tracker">
+                                               <Form.Label className="d-block">
+                                                       {t('episodes.restreamDialog.acceptTracker')}
+                                               </Form.Label>
+                                               <Form.Control
+                                                       as={ToggleSwitch}
+                                                       name="accept_tracker"
+                                                       onChange={acceptToggle}
+                                                       value={!!channel.pivot.accept_tracker}
+                                               />
+                                       </Form.Group>
+                               </Row>
+                       : null}
+                       {channel && episode && manageCrew ? <>
+                               <CrewManagement
+                                       channel={channel}
+                                       episode={episode}
+                                       manageCrew={manageCrew}
+                                       role="commentary"
+                               />
+                               <CrewManagement
+                                       channel={channel}
+                                       episode={episode}
+                                       manageCrew={manageCrew}
+                                       role="tracking"
+                               />
+                               <CrewManagement
+                                       channel={channel}
+                                       episode={episode}
+                                       manageCrew={manageCrew}
+                                       role="setup"
+                               />
+                       </> : null}
+               </Modal.Body>
+               <Modal.Footer className="justify-content-between">
+                       {onRemoveRestream ?
+                               <Button onClick={() => onRemoveRestream(episode, channel)} variant="outline-danger">
+                                       {t('button.remove')}
+                               </Button>
+                       : null}
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.close')}
+                               </Button>
+                       : null}
+               </Modal.Footer>
+       </>;
+};
+
+RestreamEditForm.propTypes = {
+       channel: PropTypes.shape({
+               id: PropTypes.number,
+               pivot: PropTypes.shape({
+                       accept_comms: PropTypes.bool,
+                       accept_tracker: PropTypes.bool,
+               }),
+               title: PropTypes.string,
+       }),
+       editRestream: PropTypes.func,
+       episode: PropTypes.shape({
+               crew: PropTypes.arrayOf(PropTypes.shape({
+                       channel_id: PropTypes.number,
+                       role: PropTypes.string,
+               })),
+               event: PropTypes.shape({
+                       title: PropTypes.string,
+               }),
+               id: PropTypes.number,
+               players: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               start: PropTypes.string,
+       }),
+       manageCrew: PropTypes.func,
+       onCancel: PropTypes.func,
+       onRemoveRestream: PropTypes.func,
+};
+
+export default RestreamEditForm;
diff --git a/resources/js/components/events/Detail.js b/resources/js/components/events/Detail.js
deleted file mode 100644 (file)
index 6589548..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from '../common/Icon';
-import RawHTML from '../common/RawHTML';
-import { hasConcluded } from '../../helpers/Event';
-import { getTranslation } from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const Detail = ({ actions, event }) => {
-       const { t } = useTranslation();
-
-       return <>
-               <div className="d-flex align-items-center justify-content-between">
-                       <h1>
-                               {(event.description && getTranslation(event.description, 'title', i18n.language))
-                                       || event.title}
-                       </h1>
-                       {event.description && actions.editContent ?
-                               <Button
-                                       className="ms-3"
-                                       onClick={() => actions.editContent(event.description)}
-                                       size="sm"
-                                       title={t('button.edit')}
-                                       variant="outline-secondary"
-                               >
-                                       <Icon.EDIT title="" />
-                               </Button>
-                       : null}
-               </div>
-               {event.description ?
-                       <RawHTML html={getTranslation(event.description, 'description', i18n.language)} />
-               : null}
-               {hasConcluded(event) ?
-                       <Alert variant="info">
-                               {t('events.concluded')}
-                       </Alert>
-               : null}
-       </>;
-};
-
-Detail.propTypes = {
-       actions: PropTypes.shape({
-               editContent: PropTypes.func,
-       }),
-       event: PropTypes.shape({
-               description: PropTypes.shape({
-               }),
-               end: PropTypes.string,
-               title: PropTypes.string,
-       }),
-};
-
-export default Detail;
diff --git a/resources/js/components/events/Detail.jsx b/resources/js/components/events/Detail.jsx
new file mode 100644 (file)
index 0000000..6589548
--- /dev/null
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import RawHTML from '../common/RawHTML';
+import { hasConcluded } from '../../helpers/Event';
+import { getTranslation } from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Detail = ({ actions, event }) => {
+       const { t } = useTranslation();
+
+       return <>
+               <div className="d-flex align-items-center justify-content-between">
+                       <h1>
+                               {(event.description && getTranslation(event.description, 'title', i18n.language))
+                                       || event.title}
+                       </h1>
+                       {event.description && actions.editContent ?
+                               <Button
+                                       className="ms-3"
+                                       onClick={() => actions.editContent(event.description)}
+                                       size="sm"
+                                       title={t('button.edit')}
+                                       variant="outline-secondary"
+                               >
+                                       <Icon.EDIT title="" />
+                               </Button>
+                       : null}
+               </div>
+               {event.description ?
+                       <RawHTML html={getTranslation(event.description, 'description', i18n.language)} />
+               : null}
+               {hasConcluded(event) ?
+                       <Alert variant="info">
+                               {t('events.concluded')}
+                       </Alert>
+               : null}
+       </>;
+};
+
+Detail.propTypes = {
+       actions: PropTypes.shape({
+               editContent: PropTypes.func,
+       }),
+       event: PropTypes.shape({
+               description: PropTypes.shape({
+               }),
+               end: PropTypes.string,
+               title: PropTypes.string,
+       }),
+};
+
+export default Detail;
diff --git a/resources/js/components/events/Item.js b/resources/js/components/events/Item.js
deleted file mode 100644 (file)
index 9217d56..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-import { Link } from 'react-router-dom';
-
-import RawHTML from '../common/RawHTML';
-import {
-       getLink,
-} from '../../helpers/Event';
-import {
-       getTranslation,
-} from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const Item = ({ event }) => {
-       const { t } = useTranslation();
-
-       const style = React.useMemo(() => {
-               if (event && event.corner) {
-                       return {
-                               backgroundImage: `url(${event.corner})`,
-                       };
-               }
-               return null;
-       }, [event && event.corner]);
-
-       return <li className="events-item my-3 p-2 pb-5 border rounded" style={style}>
-               <h3>
-                       <Link to={getLink(event)}>
-                               {(event.description && getTranslation(event.description, 'title', i18n.language))
-                                       || event.title}
-                       </Link>
-               </h3>
-               <div className="d-flex align-items-start justify-content-start">
-                       {event.start || event.end ?
-                               <div className="event-pane">
-                                       {event.start ? <>
-                                               <div><small>{t('events.start')}</small></div>
-                                               <div className="mb-2">{moment(event.start).format('LL')}</div>
-                                       </> : null}
-                                       {event.end ? <>
-                                               <div><small>{t('events.end')}</small></div>
-                                               <div className="mb-2">{moment(event.end).format('LL')}</div>
-                                       </> : null}
-                               </div>
-                       : null}
-                       {event.description?
-                               <div>
-                                       <RawHTML
-                                               html={getTranslation(event.description, 'description', i18n.language)}
-                                       />
-                               </div>
-                       : null}
-               </div>
-       </li>;
-};
-
-Item.propTypes = {
-       event: PropTypes.shape({
-               corner: PropTypes.string,
-               description: PropTypes.shape({
-               }),
-               end: PropTypes.string,
-               id: PropTypes.number,
-               name: PropTypes.string,
-               start: PropTypes.string,
-               title: PropTypes.string,
-       }),
-};
-
-export default Item;
diff --git a/resources/js/components/events/Item.jsx b/resources/js/components/events/Item.jsx
new file mode 100644 (file)
index 0000000..9217d56
--- /dev/null
@@ -0,0 +1,72 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+
+import RawHTML from '../common/RawHTML';
+import {
+       getLink,
+} from '../../helpers/Event';
+import {
+       getTranslation,
+} from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Item = ({ event }) => {
+       const { t } = useTranslation();
+
+       const style = React.useMemo(() => {
+               if (event && event.corner) {
+                       return {
+                               backgroundImage: `url(${event.corner})`,
+                       };
+               }
+               return null;
+       }, [event && event.corner]);
+
+       return <li className="events-item my-3 p-2 pb-5 border rounded" style={style}>
+               <h3>
+                       <Link to={getLink(event)}>
+                               {(event.description && getTranslation(event.description, 'title', i18n.language))
+                                       || event.title}
+                       </Link>
+               </h3>
+               <div className="d-flex align-items-start justify-content-start">
+                       {event.start || event.end ?
+                               <div className="event-pane">
+                                       {event.start ? <>
+                                               <div><small>{t('events.start')}</small></div>
+                                               <div className="mb-2">{moment(event.start).format('LL')}</div>
+                                       </> : null}
+                                       {event.end ? <>
+                                               <div><small>{t('events.end')}</small></div>
+                                               <div className="mb-2">{moment(event.end).format('LL')}</div>
+                                       </> : null}
+                               </div>
+                       : null}
+                       {event.description?
+                               <div>
+                                       <RawHTML
+                                               html={getTranslation(event.description, 'description', i18n.language)}
+                                       />
+                               </div>
+                       : null}
+               </div>
+       </li>;
+};
+
+Item.propTypes = {
+       event: PropTypes.shape({
+               corner: PropTypes.string,
+               description: PropTypes.shape({
+               }),
+               end: PropTypes.string,
+               id: PropTypes.number,
+               name: PropTypes.string,
+               start: PropTypes.string,
+               title: PropTypes.string,
+       }),
+};
+
+export default Item;
diff --git a/resources/js/components/events/List.js b/resources/js/components/events/List.js
deleted file mode 100644 (file)
index b509477..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Item from './Item';
-
-const List = ({ events }) => <ul className="event-list">
-       {events.map(event =>
-               <Item event={event} key={event.id} />
-       )}
-</ul>;
-
-List.propTypes = {
-       events: PropTypes.arrayOf(PropTypes.shape({
-               id: PropTypes.number,
-               name: PropTypes.string,
-       })),
-};
-
-export default List;
diff --git a/resources/js/components/events/List.jsx b/resources/js/components/events/List.jsx
new file mode 100644 (file)
index 0000000..b509477
--- /dev/null
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Item from './Item';
+
+const List = ({ events }) => <ul className="event-list">
+       {events.map(event =>
+               <Item event={event} key={event.id} />
+       )}
+</ul>;
+
+List.propTypes = {
+       events: PropTypes.arrayOf(PropTypes.shape({
+               id: PropTypes.number,
+               name: PropTypes.string,
+       })),
+};
+
+export default List;
diff --git a/resources/js/components/map/Buttons.js b/resources/js/components/map/Buttons.js
deleted file mode 100644 (file)
index b07fba6..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Form } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import { useOpenSeadragon } from './OpenSeadragon';
-
-const Buttons = ({ setUWOverlay, uwOverlay }) => {
-       const { activeMap, setActiveMap } = useOpenSeadragon();
-       const { t } = useTranslation();
-
-       return <div className="mt-5">
-               <div className="button-bar">
-                       {['lw', 'dw', 'sp', 'uw', 'uw2'].map(map =>
-                               <Button
-                                       active={activeMap === map}
-                                       key={map}
-                                       onClick={() => setActiveMap(map)}
-                                       title={t(`map.${map}Long`)}
-                                       variant="outline-secondary"
-                               >
-                                       {t(`map.${map}Short`)}
-                               </Button>
-                       )}
-               </div>
-               {activeMap === 'uw' ?
-                       <div className="mt-2">
-                               <Form.Check
-                                       checked={uwOverlay}
-                                       id="toggle-uw-overlay"
-                                       inline
-                                       onChange={e => setUWOverlay(e.target.checked)}
-                                       type="checkbox"
-                               />
-                               <Form.Label className="mt-0" htmlFor="toggle-uw-overlay">
-                                       {t('map.uwOverlay')}
-                               </Form.Label>
-                       </div>
-               : null}
-       </div>;
-};
-
-Buttons.propTypes = {
-       setUWOverlay: PropTypes.func,
-       uwOverlay: PropTypes.bool,
-};
-
-export default Buttons;
diff --git a/resources/js/components/map/Buttons.jsx b/resources/js/components/map/Buttons.jsx
new file mode 100644 (file)
index 0000000..b07fba6
--- /dev/null
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { useOpenSeadragon } from './OpenSeadragon';
+
+const Buttons = ({ setUWOverlay, uwOverlay }) => {
+       const { activeMap, setActiveMap } = useOpenSeadragon();
+       const { t } = useTranslation();
+
+       return <div className="mt-5">
+               <div className="button-bar">
+                       {['lw', 'dw', 'sp', 'uw', 'uw2'].map(map =>
+                               <Button
+                                       active={activeMap === map}
+                                       key={map}
+                                       onClick={() => setActiveMap(map)}
+                                       title={t(`map.${map}Long`)}
+                                       variant="outline-secondary"
+                               >
+                                       {t(`map.${map}Short`)}
+                               </Button>
+                       )}
+               </div>
+               {activeMap === 'uw' ?
+                       <div className="mt-2">
+                               <Form.Check
+                                       checked={uwOverlay}
+                                       id="toggle-uw-overlay"
+                                       inline
+                                       onChange={e => setUWOverlay(e.target.checked)}
+                                       type="checkbox"
+                               />
+                               <Form.Label className="mt-0" htmlFor="toggle-uw-overlay">
+                                       {t('map.uwOverlay')}
+                               </Form.Label>
+                       </div>
+               : null}
+       </div>;
+};
+
+Buttons.propTypes = {
+       setUWOverlay: PropTypes.func,
+       uwOverlay: PropTypes.bool,
+};
+
+export default Buttons;
diff --git a/resources/js/components/map/Item.js b/resources/js/components/map/Item.js
deleted file mode 100644 (file)
index 3fde3d1..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-import { Link, useSearchParams } from 'react-router-dom';
-
-import { useOpenSeadragon } from './OpenSeadragon';
-import Icon from '../common/Icon';
-import Rulesets from '../techniques/Rulesets';
-import {
-       getLink,
-       getRelations,
-       getTranslation,
-       hasRelations,
-       sorted,
-} from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const Item = ({ pin }) => {
-       const { viewer } = useOpenSeadragon();
-       const [, setSearchParams] = useSearchParams();
-       const { t } = useTranslation();
-
-       const goToLocation = React.useCallback(pin => {
-               setSearchParams({ x: pin.x, y: pin.y, z: 4 });
-               if (viewer && viewer.element) {
-                       viewer.element.scrollIntoView();
-               }
-       }, [viewer]);
-
-       return <li className="d-flex align-items-start justify-content-between">
-               <div className="flex-grow-1">
-                               {pin.technique.type === 'location' ? <>
-                                       <h2>{getTranslation(pin.technique, 'title', i18n.language)}</h2>
-                                       <p>{getTranslation(pin.technique, 'short', i18n.language)}</p>
-                                       {hasRelations(pin.technique, 'related') ?
-                                               sorted(getRelations(pin.technique, 'related')).map(r =>
-                                                       <div
-                                                               className="d-flex align-items-start justify-content-between"
-                                                               key={r.id}
-                                                       >
-                                                               <div className="me-auto">
-                                                                       <h3>
-                                                                               <Link to={getLink(r)}>
-                                                                                       {getTranslation(r, 'title', i18n.language)}
-                                                                               </Link>
-                                                                       </h3>
-                                                                       <p>{getTranslation(r, 'short', i18n.language)}</p>
-                                                               </div>
-                                                               {r.rulesets ?
-                                                                       <Rulesets technique={r} />
-                                                               : null}
-                                                       </div>
-                                               )
-                                       : null}
-                               </> : <div className="d-flex align-items-start justify-content-between">
-                                       <div className="flex-grow-1">
-                                               <h2>
-                                                       <Link to={getLink(pin.technique)}>
-                                                               {getTranslation(pin.technique, 'title', i18n.language)}
-                                                       </Link>
-                                               </h2>
-                                               <p>{getTranslation(pin.technique, 'short', i18n.language)}</p>
-                                       </div>
-                                       {pin.technique.rulesets ?
-                                               <Rulesets technique={pin.technique} />
-                                       : null}
-                               </div>}
-                       </div>
-               <Button
-                       className="m-2"
-                       onClick={() => goToLocation(pin)}
-                       title={t('map.goToLocation')}
-                       variant="outline-secondary"
-               >
-                       <Icon.CROSSHAIRS title="" />
-               </Button>
-       </li>;
-};
-
-Item.propTypes = {
-       pin: PropTypes.shape({
-               technique: PropTypes.shape({
-                       rulesets: PropTypes.shape({
-                       }),
-                       type: PropTypes.string,
-               }),
-               x: PropTypes.number,
-               y: PropTypes.number,
-       }),
-};
-
-export default Item;
diff --git a/resources/js/components/map/Item.jsx b/resources/js/components/map/Item.jsx
new file mode 100644 (file)
index 0000000..3fde3d1
--- /dev/null
@@ -0,0 +1,93 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import { Link, useSearchParams } from 'react-router-dom';
+
+import { useOpenSeadragon } from './OpenSeadragon';
+import Icon from '../common/Icon';
+import Rulesets from '../techniques/Rulesets';
+import {
+       getLink,
+       getRelations,
+       getTranslation,
+       hasRelations,
+       sorted,
+} from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Item = ({ pin }) => {
+       const { viewer } = useOpenSeadragon();
+       const [, setSearchParams] = useSearchParams();
+       const { t } = useTranslation();
+
+       const goToLocation = React.useCallback(pin => {
+               setSearchParams({ x: pin.x, y: pin.y, z: 4 });
+               if (viewer && viewer.element) {
+                       viewer.element.scrollIntoView();
+               }
+       }, [viewer]);
+
+       return <li className="d-flex align-items-start justify-content-between">
+               <div className="flex-grow-1">
+                               {pin.technique.type === 'location' ? <>
+                                       <h2>{getTranslation(pin.technique, 'title', i18n.language)}</h2>
+                                       <p>{getTranslation(pin.technique, 'short', i18n.language)}</p>
+                                       {hasRelations(pin.technique, 'related') ?
+                                               sorted(getRelations(pin.technique, 'related')).map(r =>
+                                                       <div
+                                                               className="d-flex align-items-start justify-content-between"
+                                                               key={r.id}
+                                                       >
+                                                               <div className="me-auto">
+                                                                       <h3>
+                                                                               <Link to={getLink(r)}>
+                                                                                       {getTranslation(r, 'title', i18n.language)}
+                                                                               </Link>
+                                                                       </h3>
+                                                                       <p>{getTranslation(r, 'short', i18n.language)}</p>
+                                                               </div>
+                                                               {r.rulesets ?
+                                                                       <Rulesets technique={r} />
+                                                               : null}
+                                                       </div>
+                                               )
+                                       : null}
+                               </> : <div className="d-flex align-items-start justify-content-between">
+                                       <div className="flex-grow-1">
+                                               <h2>
+                                                       <Link to={getLink(pin.technique)}>
+                                                               {getTranslation(pin.technique, 'title', i18n.language)}
+                                                       </Link>
+                                               </h2>
+                                               <p>{getTranslation(pin.technique, 'short', i18n.language)}</p>
+                                       </div>
+                                       {pin.technique.rulesets ?
+                                               <Rulesets technique={pin.technique} />
+                                       : null}
+                               </div>}
+                       </div>
+               <Button
+                       className="m-2"
+                       onClick={() => goToLocation(pin)}
+                       title={t('map.goToLocation')}
+                       variant="outline-secondary"
+               >
+                       <Icon.CROSSHAIRS title="" />
+               </Button>
+       </li>;
+};
+
+Item.propTypes = {
+       pin: PropTypes.shape({
+               technique: PropTypes.shape({
+                       rulesets: PropTypes.shape({
+                       }),
+                       type: PropTypes.string,
+               }),
+               x: PropTypes.number,
+               y: PropTypes.number,
+       }),
+};
+
+export default Item;
diff --git a/resources/js/components/map/List.js b/resources/js/components/map/List.js
deleted file mode 100644 (file)
index 7b66630..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import { Container } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Item from './Item';
-import { useOpenSeadragon } from './OpenSeadragon';
-import { compareTranslation } from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const List = () => {
-       const { pins } = useOpenSeadragon();
-       const { t } = useTranslation();
-
-       const sortedPins = React.useMemo(() => {
-               const compare = compareTranslation('title', i18n.language);
-               return pins.sort((a, b) => compare(a.technique, b.technique));
-       }, [pins, i18n.language]);
-
-       if (!pins || !pins.length) return null;
-
-       return <Container className="mt-3">
-               <h2>{t('map.onThisMap')}</h2>
-               <ul className="pin-list">
-                       {sortedPins.map(pin =>
-                               <Item key={pin.id} pin={pin} />
-                       )}
-               </ul>
-       </Container>;
-};
-
-export default List;
diff --git a/resources/js/components/map/List.jsx b/resources/js/components/map/List.jsx
new file mode 100644 (file)
index 0000000..7b66630
--- /dev/null
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Item from './Item';
+import { useOpenSeadragon } from './OpenSeadragon';
+import { compareTranslation } from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const List = () => {
+       const { pins } = useOpenSeadragon();
+       const { t } = useTranslation();
+
+       const sortedPins = React.useMemo(() => {
+               const compare = compareTranslation('title', i18n.language);
+               return pins.sort((a, b) => compare(a.technique, b.technique));
+       }, [pins, i18n.language]);
+
+       if (!pins || !pins.length) return null;
+
+       return <Container className="mt-3">
+               <h2>{t('map.onThisMap')}</h2>
+               <ul className="pin-list">
+                       {sortedPins.map(pin =>
+                               <Item key={pin.id} pin={pin} />
+                       )}
+               </ul>
+       </Container>;
+};
+
+export default List;
diff --git a/resources/js/components/map/OpenSeadragon.js b/resources/js/components/map/OpenSeadragon.js
deleted file mode 100644 (file)
index 55e9638..0000000
+++ /dev/null
@@ -1,171 +0,0 @@
-import axios from 'axios';
-import OpenSeadragon from 'openseadragon';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useNavigate, useParams } from 'react-router';
-import { createSearchParams, useSearchParams } from 'react-router-dom';
-
-export const Context = React.createContext({});
-
-export const useOpenSeadragon = () => React.useContext(Context);
-
-export const Provider = ({ children, containerRef }) => {
-       const { activeMap } = useParams();
-       const navigate = useNavigate();
-       const [searchParams, setSearchParams] = useSearchParams();
-       const [pins, setPins] = React.useState([]);
-       const [viewer, setViewer] = React.useState(null);
-
-       const storePosition = React.useCallback(() => {
-               if (!viewer || !viewer.viewport) return;
-               const center = viewer.viewport.getCenter();
-               const zoom = viewer.viewport.getZoom();
-               setSearchParams({ x: center.x, y: center.y, z: zoom }, { replace: true });
-       }, [setSearchParams, viewer]);
-
-       const setActiveMap = React.useCallback(map => {
-               if (viewer && viewer.viewport) {
-                       const center = viewer.viewport.getCenter();
-                       const zoom = viewer.viewport.getZoom();
-                       const params = { x: center.x, y: center.y, z: zoom };
-                       navigate(
-                               { pathname: `../${map}`, search: `?${createSearchParams(params)}` },
-                               { replace: true },
-                       );
-               } else {
-                       navigate(`../${map}`, { replace: true });
-               }
-       }, [navigate, viewer]);
-
-       React.useEffect(() => {
-               if (!viewer || !viewer.viewport) return;
-               if (searchParams.has('x') && searchParams.has('y')) {
-                       viewer.viewport.panTo(new OpenSeadragon.Point(
-                               parseFloat(searchParams.get('x')),
-                               parseFloat(searchParams.get('y')),
-                       ));
-               }
-               if (searchParams.has('z')) {
-                       viewer.viewport.zoomTo(parseFloat(searchParams.get('z')));
-               }
-       }, [searchParams, viewer]);
-
-       React.useEffect(() => {
-               if (!containerRef.current) return;
-
-               const v = OpenSeadragon({
-                       element: containerRef.current,
-                       preserveViewport: true,
-                       sequenceMode: true,
-                       showNavigator: true,
-                       showNavigationControl: false,
-                       showSequenceControl: false,
-                       tileSources: [
-                               new OpenSeadragon.DziTileSource({
-                                       width: 8192,
-                                       height: 8192,
-                                       tileSize: 256,
-                                       tileOverlap: 0,
-                                       minLevel: 8,
-                                       maxLevel: 13,
-                                       tilesUrl: '/media/alttp/map/lw_files/',
-                                       fileFormat: 'png',
-                               }), new OpenSeadragon.DziTileSource({
-                                       width: 8192,
-                                       height: 8192,
-                                       tileSize: 256,
-                                       tileOverlap: 0,
-                                       minLevel: 8,
-                                       maxLevel: 13,
-                                       tilesUrl: '/media/alttp/map/dw_files/',
-                                       fileFormat: 'png',
-                               }), new OpenSeadragon.DziTileSource({
-                                       width: 8192,
-                                       height: 4096,
-                                       tileSize: 256,
-                                       tileOverlap: 0,
-                                       minLevel: 8,
-                                       maxLevel: 13,
-                                       tilesUrl: '/media/alttp/map/sp_files/',
-                                       fileFormat: 'png',
-                               }), new OpenSeadragon.DziTileSource({
-                                       width: 16384,
-                                       height: 16384,
-                                       tileSize: 256,
-                                       tileOverlap: 0,
-                                       minLevel: 8,
-                                       maxLevel: 14,
-                                       tilesUrl: '/media/alttp/map/uw_files/',
-                                       fileFormat: 'png',
-                               }), new OpenSeadragon.DziTileSource({
-                                       width: 16384,
-                                       height: 3072,
-                                       tileSize: 256,
-                                       tileOverlap: 0,
-                                       minLevel: 8,
-                                       maxLevel: 14,
-                                       tilesUrl: '/media/alttp/map/uw2_files/',
-                                       fileFormat: 'png',
-                               }),
-                       ],
-               });
-               v.addHandler('canvas-nonprimary-press', e => {
-                       if (e.button === 3) {
-                               navigate(-1);
-                       } else if (e.button === 4) {
-                               navigate(1);
-                       }
-               });
-               setViewer(v);
-               return () => {
-                       v.destroy();
-               };
-       }, [containerRef.current]);
-
-       React.useEffect(() => {
-               if (!viewer) return;
-               switch (activeMap) {
-                       case 'lw':
-                               viewer.goToPage(0);
-                               break;
-                       case 'dw':
-                               viewer.goToPage(1);
-                               break;
-                       case 'sp':
-                               viewer.goToPage(2);
-                               break;
-                       case 'uw':
-                               viewer.goToPage(3);
-                               break;
-                       case 'uw2':
-                               viewer.goToPage(4);
-                               break;
-               }
-               const controller = new AbortController();
-               axios.get(`/api/markers/${activeMap}`, {
-                       signal: controller.signal,
-               }).then(response => {
-                       setPins(response.data || []);
-               }).catch(e => {
-                       if (!axios.isCancel(e)) {
-                               console.error(e);
-                       }
-               });
-               return () => {
-                       controller.abort();
-               };
-       }, [activeMap, viewer]);
-
-       return <Context.Provider value={{ activeMap, pins, setActiveMap, storePosition, viewer }}>
-               {children}
-       </Context.Provider>;
-};
-
-Provider.displayName = 'OpenSeadragonProvider';
-
-Provider.propTypes = {
-       children: PropTypes.node,
-       containerRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
-};
-
-export default Provider;
diff --git a/resources/js/components/map/OpenSeadragon.jsx b/resources/js/components/map/OpenSeadragon.jsx
new file mode 100644 (file)
index 0000000..55e9638
--- /dev/null
@@ -0,0 +1,171 @@
+import axios from 'axios';
+import OpenSeadragon from 'openseadragon';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useNavigate, useParams } from 'react-router';
+import { createSearchParams, useSearchParams } from 'react-router-dom';
+
+export const Context = React.createContext({});
+
+export const useOpenSeadragon = () => React.useContext(Context);
+
+export const Provider = ({ children, containerRef }) => {
+       const { activeMap } = useParams();
+       const navigate = useNavigate();
+       const [searchParams, setSearchParams] = useSearchParams();
+       const [pins, setPins] = React.useState([]);
+       const [viewer, setViewer] = React.useState(null);
+
+       const storePosition = React.useCallback(() => {
+               if (!viewer || !viewer.viewport) return;
+               const center = viewer.viewport.getCenter();
+               const zoom = viewer.viewport.getZoom();
+               setSearchParams({ x: center.x, y: center.y, z: zoom }, { replace: true });
+       }, [setSearchParams, viewer]);
+
+       const setActiveMap = React.useCallback(map => {
+               if (viewer && viewer.viewport) {
+                       const center = viewer.viewport.getCenter();
+                       const zoom = viewer.viewport.getZoom();
+                       const params = { x: center.x, y: center.y, z: zoom };
+                       navigate(
+                               { pathname: `../${map}`, search: `?${createSearchParams(params)}` },
+                               { replace: true },
+                       );
+               } else {
+                       navigate(`../${map}`, { replace: true });
+               }
+       }, [navigate, viewer]);
+
+       React.useEffect(() => {
+               if (!viewer || !viewer.viewport) return;
+               if (searchParams.has('x') && searchParams.has('y')) {
+                       viewer.viewport.panTo(new OpenSeadragon.Point(
+                               parseFloat(searchParams.get('x')),
+                               parseFloat(searchParams.get('y')),
+                       ));
+               }
+               if (searchParams.has('z')) {
+                       viewer.viewport.zoomTo(parseFloat(searchParams.get('z')));
+               }
+       }, [searchParams, viewer]);
+
+       React.useEffect(() => {
+               if (!containerRef.current) return;
+
+               const v = OpenSeadragon({
+                       element: containerRef.current,
+                       preserveViewport: true,
+                       sequenceMode: true,
+                       showNavigator: true,
+                       showNavigationControl: false,
+                       showSequenceControl: false,
+                       tileSources: [
+                               new OpenSeadragon.DziTileSource({
+                                       width: 8192,
+                                       height: 8192,
+                                       tileSize: 256,
+                                       tileOverlap: 0,
+                                       minLevel: 8,
+                                       maxLevel: 13,
+                                       tilesUrl: '/media/alttp/map/lw_files/',
+                                       fileFormat: 'png',
+                               }), new OpenSeadragon.DziTileSource({
+                                       width: 8192,
+                                       height: 8192,
+                                       tileSize: 256,
+                                       tileOverlap: 0,
+                                       minLevel: 8,
+                                       maxLevel: 13,
+                                       tilesUrl: '/media/alttp/map/dw_files/',
+                                       fileFormat: 'png',
+                               }), new OpenSeadragon.DziTileSource({
+                                       width: 8192,
+                                       height: 4096,
+                                       tileSize: 256,
+                                       tileOverlap: 0,
+                                       minLevel: 8,
+                                       maxLevel: 13,
+                                       tilesUrl: '/media/alttp/map/sp_files/',
+                                       fileFormat: 'png',
+                               }), new OpenSeadragon.DziTileSource({
+                                       width: 16384,
+                                       height: 16384,
+                                       tileSize: 256,
+                                       tileOverlap: 0,
+                                       minLevel: 8,
+                                       maxLevel: 14,
+                                       tilesUrl: '/media/alttp/map/uw_files/',
+                                       fileFormat: 'png',
+                               }), new OpenSeadragon.DziTileSource({
+                                       width: 16384,
+                                       height: 3072,
+                                       tileSize: 256,
+                                       tileOverlap: 0,
+                                       minLevel: 8,
+                                       maxLevel: 14,
+                                       tilesUrl: '/media/alttp/map/uw2_files/',
+                                       fileFormat: 'png',
+                               }),
+                       ],
+               });
+               v.addHandler('canvas-nonprimary-press', e => {
+                       if (e.button === 3) {
+                               navigate(-1);
+                       } else if (e.button === 4) {
+                               navigate(1);
+                       }
+               });
+               setViewer(v);
+               return () => {
+                       v.destroy();
+               };
+       }, [containerRef.current]);
+
+       React.useEffect(() => {
+               if (!viewer) return;
+               switch (activeMap) {
+                       case 'lw':
+                               viewer.goToPage(0);
+                               break;
+                       case 'dw':
+                               viewer.goToPage(1);
+                               break;
+                       case 'sp':
+                               viewer.goToPage(2);
+                               break;
+                       case 'uw':
+                               viewer.goToPage(3);
+                               break;
+                       case 'uw2':
+                               viewer.goToPage(4);
+                               break;
+               }
+               const controller = new AbortController();
+               axios.get(`/api/markers/${activeMap}`, {
+                       signal: controller.signal,
+               }).then(response => {
+                       setPins(response.data || []);
+               }).catch(e => {
+                       if (!axios.isCancel(e)) {
+                               console.error(e);
+                       }
+               });
+               return () => {
+                       controller.abort();
+               };
+       }, [activeMap, viewer]);
+
+       return <Context.Provider value={{ activeMap, pins, setActiveMap, storePosition, viewer }}>
+               {children}
+       </Context.Provider>;
+};
+
+Provider.displayName = 'OpenSeadragonProvider';
+
+Provider.propTypes = {
+       children: PropTypes.node,
+       containerRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
+};
+
+export default Provider;
diff --git a/resources/js/components/map/Overlay.js b/resources/js/components/map/Overlay.js
deleted file mode 100644 (file)
index 747d0e4..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-import OpenSeadragon from 'openseadragon';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { createPortal } from 'react-dom';
-
-import { useOpenSeadragon } from './OpenSeadragon';
-
-const Overlay = ({ children, height, onClick, page, width, x, y }) => {
-       const { viewer } = useOpenSeadragon();
-       const [element] = React.useState(document.createElement('div'));
-
-       React.useEffect(() => {
-               if (!viewer) return;
-               const add = () => {
-                       if (width && height) {
-                               viewer.addOverlay(
-                                       element,
-                                       new OpenSeadragon.Rect(x, y, width, height),
-                               );
-                       } else {
-                               viewer.addOverlay(
-                                       element,
-                                       new OpenSeadragon.Point(x, y),
-                                       OpenSeadragon.Placement.CENTER,
-                               );
-                       }
-                       if (onClick) {
-                               new OpenSeadragon.MouseTracker({
-                                       element,
-                                       clickHandler: onClick,
-                               });
-                       }
-               };
-               const addPage = () => {
-                       if (viewer.currentPage() === page) {
-                               add();
-                       }
-               };
-               if (typeof page !== 'undefined') {
-                       viewer.addHandler('page', addPage);
-                       return () => {
-                               viewer.removeHandler('page', addPage);
-                               try {
-                                       viewer.removeOverlay(element);
-                               } catch (e) {
-                                       // bug in OSD?
-                                       console.error(e);
-                               }
-                       };
-               }
-               if (viewer.isOpen()) {
-                       add();
-               } else {
-                       viewer.addHandler('open', add);
-                       return () => {
-                               viewer.removeHandler('open', add);
-                               try {
-                                       viewer.removeOverlay(element);
-                               } catch (e) {
-                                       // bug in OSD?
-                                       console.error(e);
-                               }
-                       };
-               }
-       }, [onClick, height, page, viewer, width, x, y]);
-
-       return createPortal(children, element);
-};
-
-Overlay.propTypes = {
-       children: PropTypes.node,
-       height: PropTypes.number,
-       onClick: PropTypes.func,
-       page: PropTypes.number,
-       width: PropTypes.number,
-       x: PropTypes.number,
-       y: PropTypes.number,
-};
-
-export default Overlay;
diff --git a/resources/js/components/map/Overlay.jsx b/resources/js/components/map/Overlay.jsx
new file mode 100644 (file)
index 0000000..747d0e4
--- /dev/null
@@ -0,0 +1,80 @@
+import OpenSeadragon from 'openseadragon';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { createPortal } from 'react-dom';
+
+import { useOpenSeadragon } from './OpenSeadragon';
+
+const Overlay = ({ children, height, onClick, page, width, x, y }) => {
+       const { viewer } = useOpenSeadragon();
+       const [element] = React.useState(document.createElement('div'));
+
+       React.useEffect(() => {
+               if (!viewer) return;
+               const add = () => {
+                       if (width && height) {
+                               viewer.addOverlay(
+                                       element,
+                                       new OpenSeadragon.Rect(x, y, width, height),
+                               );
+                       } else {
+                               viewer.addOverlay(
+                                       element,
+                                       new OpenSeadragon.Point(x, y),
+                                       OpenSeadragon.Placement.CENTER,
+                               );
+                       }
+                       if (onClick) {
+                               new OpenSeadragon.MouseTracker({
+                                       element,
+                                       clickHandler: onClick,
+                               });
+                       }
+               };
+               const addPage = () => {
+                       if (viewer.currentPage() === page) {
+                               add();
+                       }
+               };
+               if (typeof page !== 'undefined') {
+                       viewer.addHandler('page', addPage);
+                       return () => {
+                               viewer.removeHandler('page', addPage);
+                               try {
+                                       viewer.removeOverlay(element);
+                               } catch (e) {
+                                       // bug in OSD?
+                                       console.error(e);
+                               }
+                       };
+               }
+               if (viewer.isOpen()) {
+                       add();
+               } else {
+                       viewer.addHandler('open', add);
+                       return () => {
+                               viewer.removeHandler('open', add);
+                               try {
+                                       viewer.removeOverlay(element);
+                               } catch (e) {
+                                       // bug in OSD?
+                                       console.error(e);
+                               }
+                       };
+               }
+       }, [onClick, height, page, viewer, width, x, y]);
+
+       return createPortal(children, element);
+};
+
+Overlay.propTypes = {
+       children: PropTypes.node,
+       height: PropTypes.number,
+       onClick: PropTypes.func,
+       page: PropTypes.number,
+       width: PropTypes.number,
+       x: PropTypes.number,
+       y: PropTypes.number,
+};
+
+export default Overlay;
diff --git a/resources/js/components/map/Pin.js b/resources/js/components/map/Pin.js
deleted file mode 100644 (file)
index 9d9b471..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-
-import { useOpenSeadragon } from './OpenSeadragon';
-import Overlay from './Overlay';
-import Popover from './Popover';
-import Icon from '../common/Icon';
-import ZeldaIcon from '../common/ZeldaIcon';
-import { getLink, getTranslation } from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const Pin = ({ pin }) => {
-       const { storePosition } = useOpenSeadragon();
-       const [showPopover, setShowPopover] = React.useState(false);
-       const ref = React.useRef();
-
-       const navigate = useNavigate();
-
-       const onClick = React.useCallback((e) => {
-               if (ref.current && ref.current.contains(e.originalTarget)) {
-                       if (e.originalTarget.tagName === 'A') {
-                               storePosition();
-                               navigate(new URL(e.originalTarget.href).pathname);
-                       }
-               } else {
-                       if (pin.technique.type === 'location') {
-                               setShowPopover(s => !s);
-                       } else {
-                               storePosition();
-                               navigate(getLink(pin.technique));
-                       }
-               }
-       }, [pin]);
-
-       const title = React.useMemo(() => {
-               return getTranslation(pin.technique, 'title', i18n.language);
-       }, [pin, i18n.language]);
-
-       return <Overlay onClick={onClick} x={pin.x} y={pin.y}>
-               <div className="map-pin">
-                       <Link to={getLink(pin.technique)}>
-                               {pin.marker ?
-                                       <ZeldaIcon title={title} name={pin.marker} />
-                               :
-                                       <Icon.PIN title={title} />
-                               }
-                       </Link>
-                       {pin.technique.type === 'location' ?
-                               <div ref={ref}>
-                                       <Popover show={showPopover} technique={pin.technique} />
-                               </div>
-                       : null}
-               </div>
-       </Overlay>;
-};
-
-Pin.propTypes = {
-       pin: PropTypes.shape({
-               marker: PropTypes.string,
-               technique: PropTypes.shape({
-                       type: PropTypes.string,
-               }),
-               x: PropTypes.number,
-               y: PropTypes.number,
-       }),
-};
-
-export default Pin;
diff --git a/resources/js/components/map/Pin.jsx b/resources/js/components/map/Pin.jsx
new file mode 100644 (file)
index 0000000..9d9b471
--- /dev/null
@@ -0,0 +1,69 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+
+import { useOpenSeadragon } from './OpenSeadragon';
+import Overlay from './Overlay';
+import Popover from './Popover';
+import Icon from '../common/Icon';
+import ZeldaIcon from '../common/ZeldaIcon';
+import { getLink, getTranslation } from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Pin = ({ pin }) => {
+       const { storePosition } = useOpenSeadragon();
+       const [showPopover, setShowPopover] = React.useState(false);
+       const ref = React.useRef();
+
+       const navigate = useNavigate();
+
+       const onClick = React.useCallback((e) => {
+               if (ref.current && ref.current.contains(e.originalTarget)) {
+                       if (e.originalTarget.tagName === 'A') {
+                               storePosition();
+                               navigate(new URL(e.originalTarget.href).pathname);
+                       }
+               } else {
+                       if (pin.technique.type === 'location') {
+                               setShowPopover(s => !s);
+                       } else {
+                               storePosition();
+                               navigate(getLink(pin.technique));
+                       }
+               }
+       }, [pin]);
+
+       const title = React.useMemo(() => {
+               return getTranslation(pin.technique, 'title', i18n.language);
+       }, [pin, i18n.language]);
+
+       return <Overlay onClick={onClick} x={pin.x} y={pin.y}>
+               <div className="map-pin">
+                       <Link to={getLink(pin.technique)}>
+                               {pin.marker ?
+                                       <ZeldaIcon title={title} name={pin.marker} />
+                               :
+                                       <Icon.PIN title={title} />
+                               }
+                       </Link>
+                       {pin.technique.type === 'location' ?
+                               <div ref={ref}>
+                                       <Popover show={showPopover} technique={pin.technique} />
+                               </div>
+                       : null}
+               </div>
+       </Overlay>;
+};
+
+Pin.propTypes = {
+       pin: PropTypes.shape({
+               marker: PropTypes.string,
+               technique: PropTypes.shape({
+                       type: PropTypes.string,
+               }),
+               x: PropTypes.number,
+               y: PropTypes.number,
+       }),
+};
+
+export default Pin;
diff --git a/resources/js/components/map/Pins.js b/resources/js/components/map/Pins.js
deleted file mode 100644 (file)
index 8b37ee9..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-import { useOpenSeadragon } from './OpenSeadragon';
-import Pin from './Pin';
-
-const Pins = () => {
-       const { pins } = useOpenSeadragon();
-
-       return pins.map(pin =>
-               <Pin key={pin.id} pin={pin} />
-       );
-};
-
-export default Pins;
diff --git a/resources/js/components/map/Pins.jsx b/resources/js/components/map/Pins.jsx
new file mode 100644 (file)
index 0000000..8b37ee9
--- /dev/null
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { useOpenSeadragon } from './OpenSeadragon';
+import Pin from './Pin';
+
+const Pins = () => {
+       const { pins } = useOpenSeadragon();
+
+       return pins.map(pin =>
+               <Pin key={pin.id} pin={pin} />
+       );
+};
+
+export default Pins;
diff --git a/resources/js/components/map/Popover.js b/resources/js/components/map/Popover.js
deleted file mode 100644 (file)
index 7fbbcef..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Card, ListGroup } from 'react-bootstrap';
-import { Link } from 'react-router-dom';
-
-import ZeldaIcon from '../common/ZeldaIcon';
-
-import {
-       getLink,
-       getRelations,
-       getTranslation,
-       hasRelations,
-       sorted,
-} from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const Popover = ({ show, technique }) =>
-       <div className={`map-popover ${show ? 'shown' : 'hidden'}`}>
-               <Card bg="dark">
-                       <Card.Header>
-                               <Card.Title>
-                                       {getTranslation(technique, 'title', i18n.language)}
-                               </Card.Title>
-                       </Card.Header>
-                       {technique.short ?
-                               <Card.Body>
-                                       <Card.Text>
-                                               {getTranslation(technique, 'short', i18n.language)}
-                                       </Card.Text>
-                               </Card.Body>
-                       : null}
-                       {hasRelations(technique, 'related') ?
-                               <ListGroup variant="flush">
-                                       {sorted(getRelations(technique, 'related')).map(r =>
-                                               <ListGroup.Item
-                                                       key={r.id}
-                                                       title={getTranslation(r, 'short', i18n.language)}
-                                               >
-                                                       <Link to={getLink(r)}>
-                                                               {r.title_icons ?
-                                                                       <span className="tech-title-icons">
-                                                                               {r.title_icons.map(icon =>
-                                                                                       <ZeldaIcon key={icon} name={icon} />
-                                                                               )}
-                                                                       </span>
-                                                               : null}
-                                                               {getTranslation(r, 'title', i18n.language)}
-                                                       </Link>
-                                               </ListGroup.Item>
-                                       )}
-                               </ListGroup>
-                       : null}
-               </Card>
-       </div>;
-
-Popover.propTypes = {
-       show: PropTypes.bool,
-       technique: PropTypes.shape({
-               short: PropTypes.string,
-       }),
-};
-
-export default Popover;
diff --git a/resources/js/components/map/Popover.jsx b/resources/js/components/map/Popover.jsx
new file mode 100644 (file)
index 0000000..7fbbcef
--- /dev/null
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Card, ListGroup } from 'react-bootstrap';
+import { Link } from 'react-router-dom';
+
+import ZeldaIcon from '../common/ZeldaIcon';
+
+import {
+       getLink,
+       getRelations,
+       getTranslation,
+       hasRelations,
+       sorted,
+} from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Popover = ({ show, technique }) =>
+       <div className={`map-popover ${show ? 'shown' : 'hidden'}`}>
+               <Card bg="dark">
+                       <Card.Header>
+                               <Card.Title>
+                                       {getTranslation(technique, 'title', i18n.language)}
+                               </Card.Title>
+                       </Card.Header>
+                       {technique.short ?
+                               <Card.Body>
+                                       <Card.Text>
+                                               {getTranslation(technique, 'short', i18n.language)}
+                                       </Card.Text>
+                               </Card.Body>
+                       : null}
+                       {hasRelations(technique, 'related') ?
+                               <ListGroup variant="flush">
+                                       {sorted(getRelations(technique, 'related')).map(r =>
+                                               <ListGroup.Item
+                                                       key={r.id}
+                                                       title={getTranslation(r, 'short', i18n.language)}
+                                               >
+                                                       <Link to={getLink(r)}>
+                                                               {r.title_icons ?
+                                                                       <span className="tech-title-icons">
+                                                                               {r.title_icons.map(icon =>
+                                                                                       <ZeldaIcon key={icon} name={icon} />
+                                                                               )}
+                                                                       </span>
+                                                               : null}
+                                                               {getTranslation(r, 'title', i18n.language)}
+                                                       </Link>
+                                               </ListGroup.Item>
+                                       )}
+                               </ListGroup>
+                       : null}
+               </Card>
+       </div>;
+
+Popover.propTypes = {
+       show: PropTypes.bool,
+       technique: PropTypes.shape({
+               short: PropTypes.string,
+       }),
+};
+
+export default Popover;
diff --git a/resources/js/components/map/UWSuperTiles.js b/resources/js/components/map/UWSuperTiles.js
deleted file mode 100644 (file)
index 83404ac..0000000
+++ /dev/null
@@ -1,505 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useSearchParams } from 'react-router-dom';
-
-import Overlay from './Overlay';
-import { useOpenSeadragon } from './OpenSeadragon';
-
-const dropMap = {
-       '00': '10',
-       '03': '02',
-       '05': '05',
-       '06': '05',
-       '07': '17',
-       '08': '07',
-       '09': '4B',
-       '0A': '09',
-       '0B': '6A',
-       '0D': '0B',
-       '0F': '01',
-       '10': '01',
-       '12': '0D',
-       '13': '0D',
-       '14': '0D',
-       '17': '27',
-       '18': '12',
-       '19': '07',
-       '1E': '3E',
-       '20': '01',
-       '21': '0D',
-       '22': '0D',
-       '23': '0D',
-       '24': '08',
-       '27': '31',
-       '29': '07',
-       '2A': '07',
-       '2C': '12',
-       '2D': '06',
-       '2E': '06',
-       '2F': '02',
-       '31': '77',
-       '33': '08',
-       '35': '08',
-       '36': '08',
-       '37': '08',
-       '39': '29',
-       '3A': '0A',
-       '3C': '0E',
-       '3D': '96',
-       '43': '0A',
-       '44': '0A',
-       '46': '09',
-       '47': '07',
-       '48': '07',
-       '49': '07',
-       '4B': '09',
-       '4D': 'A6',
-       '4F': 'BE',
-       '54': '34',
-       '55': '09',
-       '56': '09',
-       '57': '09',
-       '58': '09',
-       '59': '07',
-       '5A': '0E',
-       '5B': '0E',
-       '5E': '7E',
-       '65': 'AC',
-       '67': '09',
-       '68': '07',
-       '6D': '0B',
-       '73': '05',
-       '74': '05',
-       '75': '08',
-       '77': 'A7',
-       '78': '9D',
-       '79': '9D',
-       '7A': '9D',
-       '7B': '9D',
-       '7C': '0E',
-       '7D': '9B',
-       '7E': '9E',
-       '81': '05',
-       '82': '05',
-       '83': '05',
-       '84': '05',
-       '85': '05',
-       '88': 'A9',
-       '89': 'A9',
-       '8A': '0E',
-       '8B': '0E',
-       '8C': '1C',
-       '8D': '0B',
-       '8F': '0C',
-       '90': '0C',
-       '92': '0C',
-       '94': '0E',
-       '95': '0E',
-       '97': 'D1',
-       '9A': '7D',
-       '9B': '7D',
-       '9C': '0E',
-       '9D': '7B',
-       '9E': 'BE',
-       '9F': '0C',
-       'A1': '0C',
-       'A3': '0C',
-       'A4': '0E',
-       'A7': '17',
-       'A8': '05',
-       'A9': '89',
-       'AA': '0A',
-       'AC': '0B',
-       'AF': '02',
-       'B1': 'B2',
-       'B2': '0C',
-       'B3': '0D',
-       'B7': '0D',
-       'B8': '05',
-       'B9': '05',
-       'BA': '0A',
-       'BB': '0A',
-       'BD': '4F',
-       'BE': '4F',
-       'BF': '02',
-       'C1': '0C',
-       'C2': '0C',
-       'C3': '0D',
-       'C5': '0D',
-       'C6': '0D',
-       'C7': '05',
-       'C8': '05',
-       'C9': '0A',
-       'CA': '0B',
-       'CB': '0B',
-       'CC': '0B',
-       'CD': 'DE',
-       'CE': 'DE',
-       'D1': 'B1',
-       'D3': '05',
-       'D4': '05',
-       'D5': '0D',
-       'D6': '0D',
-       'D7': '05',
-       'D8': '05',
-       'D9': '05',
-       'DB': '0B',
-       'DC': '0B',
-       'DD': '06',
-       'DE': '06',
-       'E1': '06',
-       'E2': '06',
-       'E3': '14',
-       'E4': '06',
-       'E5': '06',
-       'E6': '06',
-       'E7': '06',
-       'E8': 'F8',
-       'E9': 'FA',
-       'EA': 'FA',
-       'EB': 'FB',
-       'EC': 'FD',
-       'ED': 'FD',
-       'EE': 'FE',
-       'EF': 'FF',
-       'F0': '06',
-       'F1': '06',
-       'F4': '06',
-       'F5': '06',
-       'F9': '06',
-       'FE': '06',
-};
-
-const strongEG = [
-       '08',
-       '0C',
-       '15',
-       '2F',
-       '40',
-       '51',
-       '52',
-       '59',
-       '5B',
-       '60',
-       '62',
-       '66',
-       '71',
-       '72',
-       '81',
-       'A2',
-       'A8',
-       'A9',
-       'AA',
-       'B2',
-       'B3',
-       'B9',
-       'C2',
-       'C3',
-       'CB',
-       'CC',
-       'DB',
-       'DC',
-       'DF',
-       'E1',
-       'E3',
-       'FA',
-];
-
-const weakEG = [
-       '07',
-       '0A',
-       '16',
-       '28',
-       '2A',
-       '2B',
-       '34',
-       '35',
-       '36',
-       '37',
-       '3A',
-       '4D',
-       '55',
-       '61',
-       '76',
-       '99',
-       'A0',
-       'C9',
-       'E2',
-       'E4',
-       'F0',
-       'FD',
-       'FE',
-];
-
-const kick = [
-       '1B',
-       '29',
-       '3E',
-       '43',
-       '97',
-];
-
-const dark = [
-       '0B',
-       '19',
-       '21',
-       '22',
-       '32',
-       '41',
-       '42',
-       '69',
-       '6A',
-       '92',
-       '93',
-       'B5',
-       'BA',
-       'C0',
-       'D0',
-       'E5',
-       'E6',
-       'E7',
-       'F0',
-       'F1',
-];
-
-const camera = {
-       '00': ['x', 'y'],
-       '01': ['y'],
-       '02': ['y'],
-       '07': ['x', 'y'],
-       '0A': ['x', 'y'],
-       '0C': ['x', 'y'],
-       '0D': ['x', 'y'],
-       '0E': ['yu'],
-       '10': ['x'],
-       '11': ['x'],
-       '12': ['x', 'y'],
-       '13': ['x'],
-       '14': ['x', 'y'],
-       '15': ['x', 'y'],
-       '16': ['y'],
-       '17': ['x', 'y'],
-       '18': ['x'],
-       '19': ['xr'],
-       '1A': ['xl'],
-       '1B': ['yu'],
-       '1D': ['y'],
-       '1F': ['yu'],
-       '20': ['x', 'y'],
-       '21': ['y'],
-       '22': ['y'],
-       '26': ['yd'],
-       '27': ['x', 'y'],
-       '28': ['x', 'y'],
-       '2A': ['x', 'y'],
-       '2B': ['xl'],
-       '2F': ['yd'],
-       '31': ['yu'],
-       '32': ['x', 'y'],
-       '34': ['x', 'y'],
-       '35': ['y'],
-       '36': ['x', 'y'],
-       '37': ['y'],
-       '38': ['x'],
-       '39': ['yu'],
-       '3A': ['x', 'y'],
-       '3B': ['x'],
-       '3C': ['x', 'y'],
-       '3E': ['yd'],
-       '3F': ['yu'],
-       '40': ['xl'],
-       '41': ['x', 'y'],
-       '42': ['y'],
-       '43': ['yu'],
-       '44': ['xr'],
-       '45': ['xr'],
-       '46': ['y'],
-       '49': ['xr'],
-       '4A': ['y'],
-       '4B': ['yd'],
-       '4C': ['x'],
-       '4D': ['x', 'y'],
-       '4E': ['yd'],
-       '50': ['x'],
-       '51': ['x', 'y'],
-       '52': ['xl', 'yd'],
-       '53': ['xr'],
-       '54': ['x', 'y'],
-       '55': ['y'],
-       '56': ['xr'],
-       '58': ['xr'],
-       '59': ['x'],
-       '5B': ['x'],
-       '5C': ['yu'],
-       '60': ['x'],
-       '61': ['x', 'y'],
-       '62': ['x', 'y'],
-       '63': ['xr'],
-       '64': ['yu'],
-       '65': ['yu'],
-       '66': ['yd'],
-       '67': ['x'],
-       '68': ['x', 'y'],
-       '6A': ['x'],
-       '6B': ['yu'],
-       '6D': ['xr'],
-       '72': ['y'],
-       '74': ['y'],
-       '75': ['xr'],
-       '76': ['xl'],
-       '77': ['x', 'y'],
-       '7B': ['yu'],
-       '7C': ['x'],
-       '7D': ['yu'],
-       '7E': ['xr'],
-       '7F': ['xr'],
-       '80': ['y'],
-       '81': ['x', 'y'],
-       '82': ['x', 'y'],
-       '83': ['xr'],
-       '84': ['x', 'y'],
-       '85': ['xl'],
-       '89': ['y'],
-       '8B': ['xl'],
-       '8D': ['xr'],
-       '91': ['x'],
-       '92': ['xr'],
-       '93': ['yu'],
-       '95': ['x'],
-       '96': ['xl'],
-       '97': ['xr'],
-       '98': ['yd'],
-       '99': ['yd'],
-       '9B': ['yd'],
-       '9C': ['x', 'y'],
-       '9D': ['yd'],
-       'A0': ['y'],
-       'A1': ['xr', 'yu'],
-       'A2': ['x', 'y'],
-       'A3': ['x'],
-       'A5': ['yd'],
-       'A6': ['x', 'y'],
-       'A7': ['yd'],
-       'A8': ['xr'],
-       'A9': ['x', 'y'],
-       'AA': ['xl'],
-       'B1': ['xr'],
-       'B2': ['yu'],
-       'B3': ['x'],
-       'B4': ['x', 'y'],
-       'B5': ['x', 'y'],
-       'B7': ['x'],
-       'B8': ['x'],
-       'B9': ['x', 'y'],
-       'BB': ['xl'],
-       'BC': ['xr'],
-       'BE': ['xl'],
-       'C0': ['xl'],
-       'C2': ['x', 'y'],
-       'C3': ['x'],
-       'C4': ['x', 'y'],
-       'C5': ['x'],
-       'C6': ['x', 'y'],
-       'C7': ['x', 'y'],
-       'C9': ['y'],
-       'CB': ['x', 'y'],
-       'CC': ['x', 'y'],
-       'D0': ['xl'],
-       'D2': ['xr'],
-       'D5': ['x'],
-       'D6': ['x'],
-       'D8': ['xl'],
-       'D9': ['yu'],
-       'DA': ['yu'],
-       'DB': ['x', 'y'],
-       'DC': ['x', 'y'],
-       'DF': ['yd'],
-       'E1': ['x'],
-       'E2': ['x'],
-       'E4': ['x'],
-       'E5': ['x', 'y'],
-       'E6': ['xr', 'yd'],
-       'E7': ['xr', 'yu'],
-       'E8': ['x', 'yu'],
-       'EA': ['xl', 'yu'],
-       'EB': ['x'],
-       'ED': ['x', 'yu'],
-       'EE': ['x', 'y'],
-       'EF': ['yd'],
-       'F0': ['x', 'y'],
-       'F1': ['x', 'y'],
-       'F8': ['x', 'y'],
-       'F9': ['xr', 'yd'],
-       'FA': ['x', 'y'],
-       'FB': ['xr', 'yd'],
-       'FD': ['x', 'y'],
-       'FE': ['xr'],
-       'FF': ['yd'],
-};
-
-const getClassName = key => {
-       const classNames = [];
-       if (strongEG.includes(key)) {
-               classNames.push('strong-eg');
-       }
-       if (weakEG.includes(key)) {
-               classNames.push('weak-eg');
-       }
-       if (kick.includes(key)) {
-               classNames.push('kick');
-       }
-       if (dark.includes(key)) {
-               classNames.push('dark');
-       }
-       if (camera[key]) {
-               camera[key].forEach(c => {
-                       classNames.push(`cam-${c}`);
-               });
-       }
-       return classNames.join(' ');
-};
-
-const UWSuperTiles = ({ show }) => {
-       const { storePosition, viewer } = useOpenSeadragon();
-       const [, setSearchParams] = useSearchParams();
-
-       const onClick = React.useCallback(e => {
-               if (e.originalTarget.tagName !== 'A') return;
-               if (e.originalTarget.className !== 'cell-link') return;
-               const key = e.originalTarget.dataset.key;
-
-               const x = (parseInt(key[1], 16) + 0.5) / 16;
-               const y = (parseInt(key[0], 16) + 0.5) / 16;
-               if (viewer && viewer.viewport) {
-                       storePosition();
-                       setSearchParams({ x, y, z: 4 });
-                       viewer.element.scrollIntoView();
-               }
-       }, [storePosition, viewer]);
-
-       return <Overlay onClick={onClick} page={3} x={0} y={0} width={1} height={1}>
-               <div className={`uw-super-tiles ${show ? '' : 'd-none'}`}>
-                       {[...Array(16).keys()].map(x =>
-                               [...Array(16).keys()].map(y => {
-                                       const key = `${x.toString(16).toUpperCase()}${y.toString(16).toUpperCase()}`;
-                                       return <div className={getClassName(key)} key={key}>
-                                               <p className="cell-id">{key}</p>
-                                               {dropMap[key] ?
-                                                       <p className="cell-drop">
-                                                               <a className="cell-link" data-key={dropMap[key]}>
-                                                                       {`â–¶ ${dropMap[key]}`}
-                                                               </a>
-                                                       </p>
-                                               : null}
-                                       </div>;
-                               })
-                       )}
-               </div>
-       </Overlay>;
-};
-
-UWSuperTiles.propTypes = {
-       show: PropTypes.bool,
-};
-
-export default UWSuperTiles;
diff --git a/resources/js/components/map/UWSuperTiles.jsx b/resources/js/components/map/UWSuperTiles.jsx
new file mode 100644 (file)
index 0000000..83404ac
--- /dev/null
@@ -0,0 +1,505 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useSearchParams } from 'react-router-dom';
+
+import Overlay from './Overlay';
+import { useOpenSeadragon } from './OpenSeadragon';
+
+const dropMap = {
+       '00': '10',
+       '03': '02',
+       '05': '05',
+       '06': '05',
+       '07': '17',
+       '08': '07',
+       '09': '4B',
+       '0A': '09',
+       '0B': '6A',
+       '0D': '0B',
+       '0F': '01',
+       '10': '01',
+       '12': '0D',
+       '13': '0D',
+       '14': '0D',
+       '17': '27',
+       '18': '12',
+       '19': '07',
+       '1E': '3E',
+       '20': '01',
+       '21': '0D',
+       '22': '0D',
+       '23': '0D',
+       '24': '08',
+       '27': '31',
+       '29': '07',
+       '2A': '07',
+       '2C': '12',
+       '2D': '06',
+       '2E': '06',
+       '2F': '02',
+       '31': '77',
+       '33': '08',
+       '35': '08',
+       '36': '08',
+       '37': '08',
+       '39': '29',
+       '3A': '0A',
+       '3C': '0E',
+       '3D': '96',
+       '43': '0A',
+       '44': '0A',
+       '46': '09',
+       '47': '07',
+       '48': '07',
+       '49': '07',
+       '4B': '09',
+       '4D': 'A6',
+       '4F': 'BE',
+       '54': '34',
+       '55': '09',
+       '56': '09',
+       '57': '09',
+       '58': '09',
+       '59': '07',
+       '5A': '0E',
+       '5B': '0E',
+       '5E': '7E',
+       '65': 'AC',
+       '67': '09',
+       '68': '07',
+       '6D': '0B',
+       '73': '05',
+       '74': '05',
+       '75': '08',
+       '77': 'A7',
+       '78': '9D',
+       '79': '9D',
+       '7A': '9D',
+       '7B': '9D',
+       '7C': '0E',
+       '7D': '9B',
+       '7E': '9E',
+       '81': '05',
+       '82': '05',
+       '83': '05',
+       '84': '05',
+       '85': '05',
+       '88': 'A9',
+       '89': 'A9',
+       '8A': '0E',
+       '8B': '0E',
+       '8C': '1C',
+       '8D': '0B',
+       '8F': '0C',
+       '90': '0C',
+       '92': '0C',
+       '94': '0E',
+       '95': '0E',
+       '97': 'D1',
+       '9A': '7D',
+       '9B': '7D',
+       '9C': '0E',
+       '9D': '7B',
+       '9E': 'BE',
+       '9F': '0C',
+       'A1': '0C',
+       'A3': '0C',
+       'A4': '0E',
+       'A7': '17',
+       'A8': '05',
+       'A9': '89',
+       'AA': '0A',
+       'AC': '0B',
+       'AF': '02',
+       'B1': 'B2',
+       'B2': '0C',
+       'B3': '0D',
+       'B7': '0D',
+       'B8': '05',
+       'B9': '05',
+       'BA': '0A',
+       'BB': '0A',
+       'BD': '4F',
+       'BE': '4F',
+       'BF': '02',
+       'C1': '0C',
+       'C2': '0C',
+       'C3': '0D',
+       'C5': '0D',
+       'C6': '0D',
+       'C7': '05',
+       'C8': '05',
+       'C9': '0A',
+       'CA': '0B',
+       'CB': '0B',
+       'CC': '0B',
+       'CD': 'DE',
+       'CE': 'DE',
+       'D1': 'B1',
+       'D3': '05',
+       'D4': '05',
+       'D5': '0D',
+       'D6': '0D',
+       'D7': '05',
+       'D8': '05',
+       'D9': '05',
+       'DB': '0B',
+       'DC': '0B',
+       'DD': '06',
+       'DE': '06',
+       'E1': '06',
+       'E2': '06',
+       'E3': '14',
+       'E4': '06',
+       'E5': '06',
+       'E6': '06',
+       'E7': '06',
+       'E8': 'F8',
+       'E9': 'FA',
+       'EA': 'FA',
+       'EB': 'FB',
+       'EC': 'FD',
+       'ED': 'FD',
+       'EE': 'FE',
+       'EF': 'FF',
+       'F0': '06',
+       'F1': '06',
+       'F4': '06',
+       'F5': '06',
+       'F9': '06',
+       'FE': '06',
+};
+
+const strongEG = [
+       '08',
+       '0C',
+       '15',
+       '2F',
+       '40',
+       '51',
+       '52',
+       '59',
+       '5B',
+       '60',
+       '62',
+       '66',
+       '71',
+       '72',
+       '81',
+       'A2',
+       'A8',
+       'A9',
+       'AA',
+       'B2',
+       'B3',
+       'B9',
+       'C2',
+       'C3',
+       'CB',
+       'CC',
+       'DB',
+       'DC',
+       'DF',
+       'E1',
+       'E3',
+       'FA',
+];
+
+const weakEG = [
+       '07',
+       '0A',
+       '16',
+       '28',
+       '2A',
+       '2B',
+       '34',
+       '35',
+       '36',
+       '37',
+       '3A',
+       '4D',
+       '55',
+       '61',
+       '76',
+       '99',
+       'A0',
+       'C9',
+       'E2',
+       'E4',
+       'F0',
+       'FD',
+       'FE',
+];
+
+const kick = [
+       '1B',
+       '29',
+       '3E',
+       '43',
+       '97',
+];
+
+const dark = [
+       '0B',
+       '19',
+       '21',
+       '22',
+       '32',
+       '41',
+       '42',
+       '69',
+       '6A',
+       '92',
+       '93',
+       'B5',
+       'BA',
+       'C0',
+       'D0',
+       'E5',
+       'E6',
+       'E7',
+       'F0',
+       'F1',
+];
+
+const camera = {
+       '00': ['x', 'y'],
+       '01': ['y'],
+       '02': ['y'],
+       '07': ['x', 'y'],
+       '0A': ['x', 'y'],
+       '0C': ['x', 'y'],
+       '0D': ['x', 'y'],
+       '0E': ['yu'],
+       '10': ['x'],
+       '11': ['x'],
+       '12': ['x', 'y'],
+       '13': ['x'],
+       '14': ['x', 'y'],
+       '15': ['x', 'y'],
+       '16': ['y'],
+       '17': ['x', 'y'],
+       '18': ['x'],
+       '19': ['xr'],
+       '1A': ['xl'],
+       '1B': ['yu'],
+       '1D': ['y'],
+       '1F': ['yu'],
+       '20': ['x', 'y'],
+       '21': ['y'],
+       '22': ['y'],
+       '26': ['yd'],
+       '27': ['x', 'y'],
+       '28': ['x', 'y'],
+       '2A': ['x', 'y'],
+       '2B': ['xl'],
+       '2F': ['yd'],
+       '31': ['yu'],
+       '32': ['x', 'y'],
+       '34': ['x', 'y'],
+       '35': ['y'],
+       '36': ['x', 'y'],
+       '37': ['y'],
+       '38': ['x'],
+       '39': ['yu'],
+       '3A': ['x', 'y'],
+       '3B': ['x'],
+       '3C': ['x', 'y'],
+       '3E': ['yd'],
+       '3F': ['yu'],
+       '40': ['xl'],
+       '41': ['x', 'y'],
+       '42': ['y'],
+       '43': ['yu'],
+       '44': ['xr'],
+       '45': ['xr'],
+       '46': ['y'],
+       '49': ['xr'],
+       '4A': ['y'],
+       '4B': ['yd'],
+       '4C': ['x'],
+       '4D': ['x', 'y'],
+       '4E': ['yd'],
+       '50': ['x'],
+       '51': ['x', 'y'],
+       '52': ['xl', 'yd'],
+       '53': ['xr'],
+       '54': ['x', 'y'],
+       '55': ['y'],
+       '56': ['xr'],
+       '58': ['xr'],
+       '59': ['x'],
+       '5B': ['x'],
+       '5C': ['yu'],
+       '60': ['x'],
+       '61': ['x', 'y'],
+       '62': ['x', 'y'],
+       '63': ['xr'],
+       '64': ['yu'],
+       '65': ['yu'],
+       '66': ['yd'],
+       '67': ['x'],
+       '68': ['x', 'y'],
+       '6A': ['x'],
+       '6B': ['yu'],
+       '6D': ['xr'],
+       '72': ['y'],
+       '74': ['y'],
+       '75': ['xr'],
+       '76': ['xl'],
+       '77': ['x', 'y'],
+       '7B': ['yu'],
+       '7C': ['x'],
+       '7D': ['yu'],
+       '7E': ['xr'],
+       '7F': ['xr'],
+       '80': ['y'],
+       '81': ['x', 'y'],
+       '82': ['x', 'y'],
+       '83': ['xr'],
+       '84': ['x', 'y'],
+       '85': ['xl'],
+       '89': ['y'],
+       '8B': ['xl'],
+       '8D': ['xr'],
+       '91': ['x'],
+       '92': ['xr'],
+       '93': ['yu'],
+       '95': ['x'],
+       '96': ['xl'],
+       '97': ['xr'],
+       '98': ['yd'],
+       '99': ['yd'],
+       '9B': ['yd'],
+       '9C': ['x', 'y'],
+       '9D': ['yd'],
+       'A0': ['y'],
+       'A1': ['xr', 'yu'],
+       'A2': ['x', 'y'],
+       'A3': ['x'],
+       'A5': ['yd'],
+       'A6': ['x', 'y'],
+       'A7': ['yd'],
+       'A8': ['xr'],
+       'A9': ['x', 'y'],
+       'AA': ['xl'],
+       'B1': ['xr'],
+       'B2': ['yu'],
+       'B3': ['x'],
+       'B4': ['x', 'y'],
+       'B5': ['x', 'y'],
+       'B7': ['x'],
+       'B8': ['x'],
+       'B9': ['x', 'y'],
+       'BB': ['xl'],
+       'BC': ['xr'],
+       'BE': ['xl'],
+       'C0': ['xl'],
+       'C2': ['x', 'y'],
+       'C3': ['x'],
+       'C4': ['x', 'y'],
+       'C5': ['x'],
+       'C6': ['x', 'y'],
+       'C7': ['x', 'y'],
+       'C9': ['y'],
+       'CB': ['x', 'y'],
+       'CC': ['x', 'y'],
+       'D0': ['xl'],
+       'D2': ['xr'],
+       'D5': ['x'],
+       'D6': ['x'],
+       'D8': ['xl'],
+       'D9': ['yu'],
+       'DA': ['yu'],
+       'DB': ['x', 'y'],
+       'DC': ['x', 'y'],
+       'DF': ['yd'],
+       'E1': ['x'],
+       'E2': ['x'],
+       'E4': ['x'],
+       'E5': ['x', 'y'],
+       'E6': ['xr', 'yd'],
+       'E7': ['xr', 'yu'],
+       'E8': ['x', 'yu'],
+       'EA': ['xl', 'yu'],
+       'EB': ['x'],
+       'ED': ['x', 'yu'],
+       'EE': ['x', 'y'],
+       'EF': ['yd'],
+       'F0': ['x', 'y'],
+       'F1': ['x', 'y'],
+       'F8': ['x', 'y'],
+       'F9': ['xr', 'yd'],
+       'FA': ['x', 'y'],
+       'FB': ['xr', 'yd'],
+       'FD': ['x', 'y'],
+       'FE': ['xr'],
+       'FF': ['yd'],
+};
+
+const getClassName = key => {
+       const classNames = [];
+       if (strongEG.includes(key)) {
+               classNames.push('strong-eg');
+       }
+       if (weakEG.includes(key)) {
+               classNames.push('weak-eg');
+       }
+       if (kick.includes(key)) {
+               classNames.push('kick');
+       }
+       if (dark.includes(key)) {
+               classNames.push('dark');
+       }
+       if (camera[key]) {
+               camera[key].forEach(c => {
+                       classNames.push(`cam-${c}`);
+               });
+       }
+       return classNames.join(' ');
+};
+
+const UWSuperTiles = ({ show }) => {
+       const { storePosition, viewer } = useOpenSeadragon();
+       const [, setSearchParams] = useSearchParams();
+
+       const onClick = React.useCallback(e => {
+               if (e.originalTarget.tagName !== 'A') return;
+               if (e.originalTarget.className !== 'cell-link') return;
+               const key = e.originalTarget.dataset.key;
+
+               const x = (parseInt(key[1], 16) + 0.5) / 16;
+               const y = (parseInt(key[0], 16) + 0.5) / 16;
+               if (viewer && viewer.viewport) {
+                       storePosition();
+                       setSearchParams({ x, y, z: 4 });
+                       viewer.element.scrollIntoView();
+               }
+       }, [storePosition, viewer]);
+
+       return <Overlay onClick={onClick} page={3} x={0} y={0} width={1} height={1}>
+               <div className={`uw-super-tiles ${show ? '' : 'd-none'}`}>
+                       {[...Array(16).keys()].map(x =>
+                               [...Array(16).keys()].map(y => {
+                                       const key = `${x.toString(16).toUpperCase()}${y.toString(16).toUpperCase()}`;
+                                       return <div className={getClassName(key)} key={key}>
+                                               <p className="cell-id">{key}</p>
+                                               {dropMap[key] ?
+                                                       <p className="cell-drop">
+                                                               <a className="cell-link" data-key={dropMap[key]}>
+                                                                       {`â–¶ ${dropMap[key]}`}
+                                                               </a>
+                                                       </p>
+                                               : null}
+                                       </div>;
+                               })
+                       )}
+               </div>
+       </Overlay>;
+};
+
+UWSuperTiles.propTypes = {
+       show: PropTypes.bool,
+};
+
+export default UWSuperTiles;
diff --git a/resources/js/components/map/Viewer.js b/resources/js/components/map/Viewer.js
deleted file mode 100644 (file)
index 473c179..0000000
+++ /dev/null
@@ -1,109 +0,0 @@
-import OpenSeadragon from 'openseadragon';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-const Viewer = () => {
-       const [viewer, setViewer] = React.useState(null);
-
-       const container = React.useRef();
-       const { t } = useTranslation();
-
-       React.useEffect(() => {
-               if (!container.current) return;
-
-               const v = OpenSeadragon({
-                       element: container.current,
-                       preserveViewport: true,
-                       sequenceMode: true,
-                       showNavigator: true,
-                       showNavigationControl: false,
-                       showSequenceControl: false,
-                       //tileSources: [
-                       //      new OpenSeadragon.DziTileSource({
-                       //              width: 8192,
-                       //              height: 8192,
-                       //              tileSize: 256,
-                       //              tileOverlap: 0,
-                       //              minLevel: 8,
-                       //              maxLevel: 13,
-                       //              tilesUrl: '/media/alttp/map/lw_files/',
-                       //              fileFormat: 'png',
-                       //      }), new OpenSeadragon.DziTileSource({
-                       //              width: 8192,
-                       //              height: 8192,
-                       //              tileSize: 256,
-                       //              tileOverlap: 0,
-                       //              minLevel: 8,
-                       //              maxLevel: 13,
-                       //              tilesUrl: '/media/alttp/map/dw_files/',
-                       //              fileFormat: 'png',
-                       //      }), new OpenSeadragon.DziTileSource({
-                       //              width: 8192,
-                       //              height: 8192,
-                       //              tileSize: 256,
-                       //              tileOverlap: 0,
-                       //              minLevel: 8,
-                       //              maxLevel: 13,
-                       //              tilesUrl: '/media/alttp/map/sp_files/',
-                       //              fileFormat: 'png',
-                       //      }), new OpenSeadragon.DziTileSource({
-                       //              width: 16384,
-                       //              height: 16384,
-                       //              tileSize: 256,
-                       //              tileOverlap: 0,
-                       //              minLevel: 8,
-                       //              maxLevel: 14,
-                       //              tilesUrl: '/media/alttp/map/uw_files/',
-                       //              fileFormat: 'png',
-                       //      }),
-                       //],
-               });
-               setViewer(v);
-               return () => {
-                       v.destroy();
-               };
-       }, [container.current]);
-
-       const goToPage = React.useCallback((p) => {
-               if (viewer) viewer.goToPage(p);
-       }, [viewer]);
-
-       return <>
-               <div className="d-flex align-items-center justify-content-between">
-                       <div className="button-bar">
-                               <Button
-                                       onClick={() => goToPage(0)}
-                                       title={t('map.lwLong')}
-                                       variant="outline-secondary"
-                               >
-                                       {t('map.lwShort')}
-                               </Button>
-                               <Button
-                                       onClick={() => goToPage(1)}
-                                       title={t('map.dwLong')}
-                                       variant="outline-secondary"
-                               >
-                                       {t('map.dwShort')}
-                               </Button>
-                               <Button
-                                       onClick={() => goToPage(2)}
-                                       title={t('map.spLong')}
-                                       variant="outline-secondary"
-                               >
-                                       {t('map.spShort')}
-                               </Button>
-                               <Button
-                                       onClick={() => goToPage(3)}
-                                       title={t('map.uwLong')}
-                                       variant="outline-secondary"
-                               >
-                                       {t('map.uwShort')}
-                               </Button>
-                       </div>
-               </div>
-               <div ref={container} style={{ height: '80vh' }} />
-       </>;
-};
-
-export default Viewer;
diff --git a/resources/js/components/map/Viewer.jsx b/resources/js/components/map/Viewer.jsx
new file mode 100644 (file)
index 0000000..473c179
--- /dev/null
@@ -0,0 +1,109 @@
+import OpenSeadragon from 'openseadragon';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const Viewer = () => {
+       const [viewer, setViewer] = React.useState(null);
+
+       const container = React.useRef();
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               if (!container.current) return;
+
+               const v = OpenSeadragon({
+                       element: container.current,
+                       preserveViewport: true,
+                       sequenceMode: true,
+                       showNavigator: true,
+                       showNavigationControl: false,
+                       showSequenceControl: false,
+                       //tileSources: [
+                       //      new OpenSeadragon.DziTileSource({
+                       //              width: 8192,
+                       //              height: 8192,
+                       //              tileSize: 256,
+                       //              tileOverlap: 0,
+                       //              minLevel: 8,
+                       //              maxLevel: 13,
+                       //              tilesUrl: '/media/alttp/map/lw_files/',
+                       //              fileFormat: 'png',
+                       //      }), new OpenSeadragon.DziTileSource({
+                       //              width: 8192,
+                       //              height: 8192,
+                       //              tileSize: 256,
+                       //              tileOverlap: 0,
+                       //              minLevel: 8,
+                       //              maxLevel: 13,
+                       //              tilesUrl: '/media/alttp/map/dw_files/',
+                       //              fileFormat: 'png',
+                       //      }), new OpenSeadragon.DziTileSource({
+                       //              width: 8192,
+                       //              height: 8192,
+                       //              tileSize: 256,
+                       //              tileOverlap: 0,
+                       //              minLevel: 8,
+                       //              maxLevel: 13,
+                       //              tilesUrl: '/media/alttp/map/sp_files/',
+                       //              fileFormat: 'png',
+                       //      }), new OpenSeadragon.DziTileSource({
+                       //              width: 16384,
+                       //              height: 16384,
+                       //              tileSize: 256,
+                       //              tileOverlap: 0,
+                       //              minLevel: 8,
+                       //              maxLevel: 14,
+                       //              tilesUrl: '/media/alttp/map/uw_files/',
+                       //              fileFormat: 'png',
+                       //      }),
+                       //],
+               });
+               setViewer(v);
+               return () => {
+                       v.destroy();
+               };
+       }, [container.current]);
+
+       const goToPage = React.useCallback((p) => {
+               if (viewer) viewer.goToPage(p);
+       }, [viewer]);
+
+       return <>
+               <div className="d-flex align-items-center justify-content-between">
+                       <div className="button-bar">
+                               <Button
+                                       onClick={() => goToPage(0)}
+                                       title={t('map.lwLong')}
+                                       variant="outline-secondary"
+                               >
+                                       {t('map.lwShort')}
+                               </Button>
+                               <Button
+                                       onClick={() => goToPage(1)}
+                                       title={t('map.dwLong')}
+                                       variant="outline-secondary"
+                               >
+                                       {t('map.dwShort')}
+                               </Button>
+                               <Button
+                                       onClick={() => goToPage(2)}
+                                       title={t('map.spLong')}
+                                       variant="outline-secondary"
+                               >
+                                       {t('map.spShort')}
+                               </Button>
+                               <Button
+                                       onClick={() => goToPage(3)}
+                                       title={t('map.uwLong')}
+                                       variant="outline-secondary"
+                               >
+                                       {t('map.uwShort')}
+                               </Button>
+                       </div>
+               </div>
+               <div ref={container} style={{ height: '80vh' }} />
+       </>;
+};
+
+export default Viewer;
diff --git a/resources/js/components/participants/List.js b/resources/js/components/participants/List.js
deleted file mode 100644 (file)
index e210f65..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Col, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import Box from '../users/Box';
-import i18n from '../../i18n';
-
-const List = ({ participants }) => participants && participants.length ?
-       <Row className="participants">
-               {participants.map(participant =>
-                       <Col md={4} lg={3} key={participant.id}>
-                               <Box user={participant.user} />
-                       </Col>
-               )}
-       </Row>
-:
-       <Alert variant="info">
-               {i18n.t('participants.empty')}
-       </Alert>
-;
-
-List.propTypes = {
-       participants: PropTypes.arrayOf(PropTypes.shape({
-               id: PropTypes.number,
-               user: PropTypes.shape({
-                       discriminator: PropTypes.string,
-                       username: PropTypes.string,
-               }),
-       })),
-};
-
-export default withTranslation()(List);
diff --git a/resources/js/components/participants/List.jsx b/resources/js/components/participants/List.jsx
new file mode 100644 (file)
index 0000000..e210f65
--- /dev/null
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Col, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Box from '../users/Box';
+import i18n from '../../i18n';
+
+const List = ({ participants }) => participants && participants.length ?
+       <Row className="participants">
+               {participants.map(participant =>
+                       <Col md={4} lg={3} key={participant.id}>
+                               <Box user={participant.user} />
+                       </Col>
+               )}
+       </Row>
+:
+       <Alert variant="info">
+               {i18n.t('participants.empty')}
+       </Alert>
+;
+
+List.propTypes = {
+       participants: PropTypes.arrayOf(PropTypes.shape({
+               id: PropTypes.number,
+               user: PropTypes.shape({
+                       discriminator: PropTypes.string,
+                       username: PropTypes.string,
+               }),
+       })),
+};
+
+export default withTranslation()(List);
diff --git a/resources/js/components/protocol/Dialog.js b/resources/js/components/protocol/Dialog.js
deleted file mode 100644 (file)
index dcf064b..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button, Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import List from './List';
-
-const Dialog = ({
-       onHide = null,
-       protocol = null,
-       show = false,
-}) => {
-       const { t } = useTranslation();
-
-       return <Modal className="protocol-dialog" onHide={onHide} show={show} size="lg">
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('protocol.heading')}
-                       </Modal.Title>
-               </Modal.Header>
-               {protocol && protocol.length ?
-                       <List protocol={protocol} />
-               :
-                       <Modal.Body>
-                               <Alert variant="info">
-                                       {t('protocol.empty')}
-                               </Alert>
-                       </Modal.Body>
-               }
-               <Modal.Footer>
-                       <Button onClick={onHide} variant="secondary">
-                               {t('button.close')}
-                       </Button>
-               </Modal.Footer>
-       </Modal>;
-};
-
-Dialog.propTypes = {
-       onHide: PropTypes.func,
-       protocol: PropTypes.arrayOf(PropTypes.shape({
-               type: PropTypes.string,
-       })),
-       show: PropTypes.bool,
-};
-
-export default Dialog;
diff --git a/resources/js/components/protocol/Dialog.jsx b/resources/js/components/protocol/Dialog.jsx
new file mode 100644 (file)
index 0000000..dcf064b
--- /dev/null
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import List from './List';
+
+const Dialog = ({
+       onHide = null,
+       protocol = null,
+       show = false,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal className="protocol-dialog" onHide={onHide} show={show} size="lg">
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('protocol.heading')}
+                       </Modal.Title>
+               </Modal.Header>
+               {protocol && protocol.length ?
+                       <List protocol={protocol} />
+               :
+                       <Modal.Body>
+                               <Alert variant="info">
+                                       {t('protocol.empty')}
+                               </Alert>
+                       </Modal.Body>
+               }
+               <Modal.Footer>
+                       <Button onClick={onHide} variant="secondary">
+                               {t('button.close')}
+                       </Button>
+               </Modal.Footer>
+       </Modal>;
+};
+
+Dialog.propTypes = {
+       onHide: PropTypes.func,
+       protocol: PropTypes.arrayOf(PropTypes.shape({
+               type: PropTypes.string,
+       })),
+       show: PropTypes.bool,
+};
+
+export default Dialog;
diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js
deleted file mode 100644 (file)
index 17dd744..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { ListGroup } from 'react-bootstrap';
-import { Trans, useTranslation } from 'react-i18next';
-
-import Icon from '../common/Icon';
-import Spoiler from '../common/Spoiler';
-import { formatTime } from '../../helpers/Result';
-import { getUserName } from '../../helpers/User';
-
-const getEntryDate = entry => {
-       const dateStr = moment(entry.created_at).fromNow();
-       return entry.user
-               ? `${entry.user.username} ${dateStr}`
-               : dateStr;
-};
-
-const getEntryDetailsUsername = entry => {
-       if (!entry || !entry.details || !entry.details.user) return 'Anonymous';
-       return getUserName(entry.details.user);
-};
-
-const getEntryRoundNumber = entry =>
-       (entry && entry.details && entry.details.round && entry.details.round.number) || '?';
-
-const getEntryResultComment = entry => {
-       if (!entry || !entry.details || !entry.details.result || !entry.details.result.comment) {
-               return '';
-       }
-       return entry.details.result.comment;
-};
-
-const getEntryResultTime = entry => {
-       if (!entry || !entry.details || !entry.details.result) return 'ERROR';
-       const result = entry.details.result;
-       if (result.forfeit) return 'DNF XX';
-       return formatTime(result);
-};
-
-const getEntryDescription = (entry, t) => {
-       switch (entry.type) {
-               case 'application.accepted':
-               case 'application.received':
-               case 'application.rejected':
-                       return t(
-                               `protocol.description.${entry.type}`,
-                               {
-                                       ...entry,
-                                       username: getEntryDetailsUsername(entry),
-                               },
-                       );
-               case 'result.comment': {
-                       const comment = getEntryResultComment(entry);
-                       const number = getEntryRoundNumber(entry);
-                       return <Trans i18nKey={`protocol.description.${entry.type}`}>
-                               {{number}}
-                               <Spoiler>{{comment}}</Spoiler>,
-                       </Trans>;
-               }
-               case 'result.report': {
-                       const number = getEntryRoundNumber(entry);
-                       const time = getEntryResultTime(entry);
-                       return <Trans i18nKey={`protocol.description.${entry.type}`}>
-                               {{number}}
-                               <Spoiler>{{time}}</Spoiler>,
-                       </Trans>;
-               }
-               case 'round.create':
-               case 'round.delete':
-               case 'round.edit':
-               case 'round.lock':
-               case 'round.seed':
-               case 'round.unlock':
-                       return t(
-                               `protocol.description.${entry.type}`,
-                               {
-                                       ...entry,
-                                       number: getEntryRoundNumber(entry),
-                               },
-                       );
-               case 'tournament.close':
-               case 'tournament.discord':
-               case 'tournament.lock':
-               case 'tournament.open':
-               case 'tournament.settings':
-               case 'tournament.unlock':
-                       return t(
-                               `protocol.description.${entry.type}`,
-                               entry,
-                       );
-               default:
-                       return t('protocol.description.unknown', entry);
-       }
-};
-
-const getEntryIcon = entry => {
-       switch (entry.type) {
-               case 'result.report':
-                       return <Icon.RESULT />;
-               case 'round.create':
-                       return <Icon.ADD />;
-               case 'round.delete':
-                       return <Icon.REMOVE />;
-               case 'round.lock':
-               case 'tournament.close':
-               case 'tournament.lock':
-                       return <Icon.LOCKED />;
-               case 'round.unlock':
-               case 'tournament.open':
-               case 'tournament.unlock':
-                       return <Icon.UNLOCKED />;
-               case 'tournament.discord':
-                       return <Icon.DISCORD />;
-               default:
-                       return <Icon.PROTOCOL />;
-       }
-};
-
-const Item = ({ entry = {} }) => {
-       const { t } = useTranslation();
-
-       const icon = React.useMemo(() => getEntryIcon(entry), [entry]);
-       const description = React.useMemo(() => getEntryDescription(entry, t), [entry, t]);
-       const [date, setDate] = React.useState(getEntryDate(entry));
-
-       React.useEffect(() => {
-               setDate(getEntryDate(entry));
-               const timer = setInterval(() => {
-                       setDate(getEntryDate(entry));
-               }, 30_000);
-               return () => {
-                       clearInterval(timer);
-               };
-       }, [entry]);
-
-       return <ListGroup.Item className="d-flex align-items-center">
-               <div className="pe-3 text-muted">
-                       {icon}
-               </div>
-               <div>
-                       <div>
-                               {description}
-                       </div>
-                       <div
-                               className="text-muted"
-                               title={moment(entry.created_at).format('LLLL')}
-                       >
-                               {date}
-                       </div>
-               </div>
-       </ListGroup.Item>;
-};
-
-Item.propTypes = {
-       entry: PropTypes.shape({
-               created_at: PropTypes.string,
-       }),
-};
-
-export default Item;
diff --git a/resources/js/components/protocol/Item.jsx b/resources/js/components/protocol/Item.jsx
new file mode 100644 (file)
index 0000000..17dd744
--- /dev/null
@@ -0,0 +1,161 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ListGroup } from 'react-bootstrap';
+import { Trans, useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import Spoiler from '../common/Spoiler';
+import { formatTime } from '../../helpers/Result';
+import { getUserName } from '../../helpers/User';
+
+const getEntryDate = entry => {
+       const dateStr = moment(entry.created_at).fromNow();
+       return entry.user
+               ? `${entry.user.username} ${dateStr}`
+               : dateStr;
+};
+
+const getEntryDetailsUsername = entry => {
+       if (!entry || !entry.details || !entry.details.user) return 'Anonymous';
+       return getUserName(entry.details.user);
+};
+
+const getEntryRoundNumber = entry =>
+       (entry && entry.details && entry.details.round && entry.details.round.number) || '?';
+
+const getEntryResultComment = entry => {
+       if (!entry || !entry.details || !entry.details.result || !entry.details.result.comment) {
+               return '';
+       }
+       return entry.details.result.comment;
+};
+
+const getEntryResultTime = entry => {
+       if (!entry || !entry.details || !entry.details.result) return 'ERROR';
+       const result = entry.details.result;
+       if (result.forfeit) return 'DNF XX';
+       return formatTime(result);
+};
+
+const getEntryDescription = (entry, t) => {
+       switch (entry.type) {
+               case 'application.accepted':
+               case 'application.received':
+               case 'application.rejected':
+                       return t(
+                               `protocol.description.${entry.type}`,
+                               {
+                                       ...entry,
+                                       username: getEntryDetailsUsername(entry),
+                               },
+                       );
+               case 'result.comment': {
+                       const comment = getEntryResultComment(entry);
+                       const number = getEntryRoundNumber(entry);
+                       return <Trans i18nKey={`protocol.description.${entry.type}`}>
+                               {{number}}
+                               <Spoiler>{{comment}}</Spoiler>,
+                       </Trans>;
+               }
+               case 'result.report': {
+                       const number = getEntryRoundNumber(entry);
+                       const time = getEntryResultTime(entry);
+                       return <Trans i18nKey={`protocol.description.${entry.type}`}>
+                               {{number}}
+                               <Spoiler>{{time}}</Spoiler>,
+                       </Trans>;
+               }
+               case 'round.create':
+               case 'round.delete':
+               case 'round.edit':
+               case 'round.lock':
+               case 'round.seed':
+               case 'round.unlock':
+                       return t(
+                               `protocol.description.${entry.type}`,
+                               {
+                                       ...entry,
+                                       number: getEntryRoundNumber(entry),
+                               },
+                       );
+               case 'tournament.close':
+               case 'tournament.discord':
+               case 'tournament.lock':
+               case 'tournament.open':
+               case 'tournament.settings':
+               case 'tournament.unlock':
+                       return t(
+                               `protocol.description.${entry.type}`,
+                               entry,
+                       );
+               default:
+                       return t('protocol.description.unknown', entry);
+       }
+};
+
+const getEntryIcon = entry => {
+       switch (entry.type) {
+               case 'result.report':
+                       return <Icon.RESULT />;
+               case 'round.create':
+                       return <Icon.ADD />;
+               case 'round.delete':
+                       return <Icon.REMOVE />;
+               case 'round.lock':
+               case 'tournament.close':
+               case 'tournament.lock':
+                       return <Icon.LOCKED />;
+               case 'round.unlock':
+               case 'tournament.open':
+               case 'tournament.unlock':
+                       return <Icon.UNLOCKED />;
+               case 'tournament.discord':
+                       return <Icon.DISCORD />;
+               default:
+                       return <Icon.PROTOCOL />;
+       }
+};
+
+const Item = ({ entry = {} }) => {
+       const { t } = useTranslation();
+
+       const icon = React.useMemo(() => getEntryIcon(entry), [entry]);
+       const description = React.useMemo(() => getEntryDescription(entry, t), [entry, t]);
+       const [date, setDate] = React.useState(getEntryDate(entry));
+
+       React.useEffect(() => {
+               setDate(getEntryDate(entry));
+               const timer = setInterval(() => {
+                       setDate(getEntryDate(entry));
+               }, 30_000);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [entry]);
+
+       return <ListGroup.Item className="d-flex align-items-center">
+               <div className="pe-3 text-muted">
+                       {icon}
+               </div>
+               <div>
+                       <div>
+                               {description}
+                       </div>
+                       <div
+                               className="text-muted"
+                               title={moment(entry.created_at).format('LLLL')}
+                       >
+                               {date}
+                       </div>
+               </div>
+       </ListGroup.Item>;
+};
+
+Item.propTypes = {
+       entry: PropTypes.shape({
+               created_at: PropTypes.string,
+       }),
+};
+
+export default Item;
diff --git a/resources/js/components/protocol/List.js b/resources/js/components/protocol/List.js
deleted file mode 100644 (file)
index 9179ad5..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { ListGroup } from 'react-bootstrap';
-
-import Item from './Item';
-
-const List = ({ protocol = [] }) =>
-       <ListGroup variant="flush">
-               {protocol ? protocol.map(entry =>
-                       <Item key={entry.id} entry={entry} />
-               ) : null}
-       </ListGroup>;
-
-List.propTypes = {
-       protocol: PropTypes.arrayOf(PropTypes.shape({
-       })),
-};
-
-export default List;
diff --git a/resources/js/components/protocol/List.jsx b/resources/js/components/protocol/List.jsx
new file mode 100644 (file)
index 0000000..9179ad5
--- /dev/null
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ListGroup } from 'react-bootstrap';
+
+import Item from './Item';
+
+const List = ({ protocol = [] }) =>
+       <ListGroup variant="flush">
+               {protocol ? protocol.map(entry =>
+                       <Item key={entry.id} entry={entry} />
+               ) : null}
+       </ListGroup>;
+
+List.propTypes = {
+       protocol: PropTypes.arrayOf(PropTypes.shape({
+       })),
+};
+
+export default List;
diff --git a/resources/js/components/protocol/Protocol.js b/resources/js/components/protocol/Protocol.js
deleted file mode 100644 (file)
index 4b832c7..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useEffect, useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Dialog from './Dialog';
-import Icon from '../common/Icon';
-
-const Protocol = ({ id }) => {
-       const [showDialog, setShowDialog] = useState(false);
-       const [protocol, setProtocol] = useState([]);
-
-       const { t } = useTranslation();
-
-       useEffect(() => {
-               const ctrl = new AbortController();
-               axios
-                       .get(`/api/protocol/${id}`, { signal: ctrl.signal })
-                       .then(response => {
-                               setProtocol(response.data);
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [id]);
-
-       useEffect(() => {
-               window.Echo.private(`Protocol.${id}`)
-                       .listen('ProtocolAdded', e => {
-                               if (e.protocol) {
-                                       setProtocol(protocol => [e.protocol, ...protocol]);
-                               }
-                       });
-               return () => {
-                       window.Echo.leave(`Protocol.${id}`);
-               };
-       }, [id]);
-
-       return (
-               <>
-                       <Button
-                               onClick={() => setShowDialog(true)}
-                               title={t('button.protocol')}
-                               variant="outline-info"
-                       >
-                               <Icon.PROTOCOL title="" />
-                       </Button>
-                       <Dialog
-                               onHide={() => setShowDialog(false)}
-                               protocol={protocol}
-                               show={showDialog}
-                       />
-               </>
-       );
-};
-
-Protocol.propTypes = {
-       id: PropTypes.number,
-};
-
-export default Protocol;
diff --git a/resources/js/components/protocol/Protocol.jsx b/resources/js/components/protocol/Protocol.jsx
new file mode 100644 (file)
index 0000000..4b832c7
--- /dev/null
@@ -0,0 +1,62 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useEffect, useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Dialog from './Dialog';
+import Icon from '../common/Icon';
+
+const Protocol = ({ id }) => {
+       const [showDialog, setShowDialog] = useState(false);
+       const [protocol, setProtocol] = useState([]);
+
+       const { t } = useTranslation();
+
+       useEffect(() => {
+               const ctrl = new AbortController();
+               axios
+                       .get(`/api/protocol/${id}`, { signal: ctrl.signal })
+                       .then(response => {
+                               setProtocol(response.data);
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [id]);
+
+       useEffect(() => {
+               window.Echo.private(`Protocol.${id}`)
+                       .listen('ProtocolAdded', e => {
+                               if (e.protocol) {
+                                       setProtocol(protocol => [e.protocol, ...protocol]);
+                               }
+                       });
+               return () => {
+                       window.Echo.leave(`Protocol.${id}`);
+               };
+       }, [id]);
+
+       return (
+               <>
+                       <Button
+                               onClick={() => setShowDialog(true)}
+                               title={t('button.protocol')}
+                               variant="outline-info"
+                       >
+                               <Icon.PROTOCOL title="" />
+                       </Button>
+                       <Dialog
+                               onHide={() => setShowDialog(false)}
+                               protocol={protocol}
+                               show={showDialog}
+                       />
+               </>
+       );
+};
+
+Protocol.propTypes = {
+       id: PropTypes.number,
+};
+
+export default Protocol;
diff --git a/resources/js/components/protocol/RoundProtocol.js b/resources/js/components/protocol/RoundProtocol.js
deleted file mode 100644 (file)
index 8f7648c..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useEffect, useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Dialog from './Dialog';
-import Icon from '../common/Icon';
-
-const RoundProtocol = ({ roundId, tournamentId }) => {
-       const [showDialog, setShowDialog] = useState(false);
-       const [protocol, setProtocol] = useState([]);
-
-       const { t } = useTranslation();
-
-       useEffect(() => {
-               if (!showDialog) return;
-               const ctrl = new AbortController();
-               axios
-                       .get(`/api/protocol/${tournamentId}/${roundId}`, { signal: ctrl.signal })
-                       .then(response => {
-                               setProtocol(response.data);
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [roundId, showDialog, tournamentId]);
-
-       return (
-               <>
-                       <Button
-                               onClick={() => setShowDialog(true)}
-                               size="sm"
-                               title={t('button.protocol')}
-                               variant="outline-info"
-                       >
-                               <Icon.PROTOCOL title="" />
-                       </Button>
-                       <Dialog
-                               onHide={() => setShowDialog(false)}
-                               protocol={protocol}
-                               show={showDialog}
-                       />
-               </>
-       );
-};
-
-RoundProtocol.propTypes = {
-       roundId: PropTypes.number,
-       tournamentId: PropTypes.number,
-};
-
-export default RoundProtocol;
diff --git a/resources/js/components/protocol/RoundProtocol.jsx b/resources/js/components/protocol/RoundProtocol.jsx
new file mode 100644 (file)
index 0000000..8f7648c
--- /dev/null
@@ -0,0 +1,53 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useEffect, useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Dialog from './Dialog';
+import Icon from '../common/Icon';
+
+const RoundProtocol = ({ roundId, tournamentId }) => {
+       const [showDialog, setShowDialog] = useState(false);
+       const [protocol, setProtocol] = useState([]);
+
+       const { t } = useTranslation();
+
+       useEffect(() => {
+               if (!showDialog) return;
+               const ctrl = new AbortController();
+               axios
+                       .get(`/api/protocol/${tournamentId}/${roundId}`, { signal: ctrl.signal })
+                       .then(response => {
+                               setProtocol(response.data);
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [roundId, showDialog, tournamentId]);
+
+       return (
+               <>
+                       <Button
+                               onClick={() => setShowDialog(true)}
+                               size="sm"
+                               title={t('button.protocol')}
+                               variant="outline-info"
+                       >
+                               <Icon.PROTOCOL title="" />
+                       </Button>
+                       <Dialog
+                               onHide={() => setShowDialog(false)}
+                               protocol={protocol}
+                               show={showDialog}
+                       />
+               </>
+       );
+};
+
+RoundProtocol.propTypes = {
+       roundId: PropTypes.number,
+       tournamentId: PropTypes.number,
+};
+
+export default RoundProtocol;
diff --git a/resources/js/components/results/DetailDialog.js b/resources/js/components/results/DetailDialog.js
deleted file mode 100644 (file)
index 60e6b74..0000000
+++ /dev/null
@@ -1,99 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Box from '../users/Box';
-import { getTime } from '../../helpers/Result';
-import { maySeeResult } from '../../helpers/permissions';
-import { findResult } from '../../helpers/User';
-import { useUser } from '../../hooks/user';
-
-const getPlacement = (result, t) =>
-       `${result.placement}. (${t('results.points', { count: result.score })})`;
-
-const DetailDialog = ({
-       onHide,
-       round,
-       show,
-       tournament,
-       user,
-}) => {
-       const { t } = useTranslation();
-       const { user: authUser } = useUser();
-
-       const result = React.useMemo(
-               () => findResult(user, round),
-               [round, user],
-       );
-       const maySee = React.useMemo(
-               () => maySeeResult(authUser, tournament, round, result),
-               [authUser, result, round, tournament],
-       );
-
-       return <Modal className="result-dialog" onHide={onHide} show={show}>
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('results.details')}
-                       </Modal.Title>
-               </Modal.Header>
-               <Modal.Body>
-                       <Row>
-                               <Form.Group as={Col} sm={6}>
-                                       <Form.Label>{t('results.round')}</Form.Label>
-                                       <div>
-                                               #{round.number || '?'}
-                                               {' '}
-                                               {t('rounds.date', { date: new Date(round.created_at) })}
-                                       </div>
-                               </Form.Group>
-                               <Form.Group as={Col} sm={6}>
-                                       <Form.Label>{t('results.runner')}</Form.Label>
-                                       <div><Box user={user} /></div>
-                               </Form.Group>
-                               <Form.Group as={Col} sm={6}>
-                                       <Form.Label>{t('results.result')}</Form.Label>
-                                       <div>
-                                               {maySee && result && result.has_finished
-                                                       ? getTime(result, maySee)
-                                                       : t('results.pending')}
-                                       </div>
-                               </Form.Group>
-                               <Form.Group as={Col} sm={6}>
-                                       <Form.Label>{t('results.placement')}</Form.Label>
-                                       <div>
-                                               {maySee && result && result.placement
-                                                       ? getPlacement(result, t)
-                                                       : t('results.pending')}
-                                       </div>
-                               </Form.Group>
-                               {maySee && result && result.comment ?
-                                       <Form.Group as={Col} sm={12}>
-                                               <Form.Label>{t('results.comment')}</Form.Label>
-                                               <div>{result.comment}</div>
-                                       </Form.Group>
-                               : null}
-                       </Row>
-               </Modal.Body>
-               <Modal.Footer>
-                       <Button onClick={onHide} variant="secondary">
-                               {t('button.close')}
-                       </Button>
-               </Modal.Footer>
-       </Modal>;
-};
-
-DetailDialog.propTypes = {
-       onHide: PropTypes.func,
-       round: PropTypes.shape({
-               created_at: PropTypes.string,
-               number: PropTypes.number,
-       }),
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-       }),
-       user: PropTypes.shape({
-       }),
-};
-
-export default DetailDialog;
diff --git a/resources/js/components/results/DetailDialog.jsx b/resources/js/components/results/DetailDialog.jsx
new file mode 100644 (file)
index 0000000..60e6b74
--- /dev/null
@@ -0,0 +1,99 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Box from '../users/Box';
+import { getTime } from '../../helpers/Result';
+import { maySeeResult } from '../../helpers/permissions';
+import { findResult } from '../../helpers/User';
+import { useUser } from '../../hooks/user';
+
+const getPlacement = (result, t) =>
+       `${result.placement}. (${t('results.points', { count: result.score })})`;
+
+const DetailDialog = ({
+       onHide,
+       round,
+       show,
+       tournament,
+       user,
+}) => {
+       const { t } = useTranslation();
+       const { user: authUser } = useUser();
+
+       const result = React.useMemo(
+               () => findResult(user, round),
+               [round, user],
+       );
+       const maySee = React.useMemo(
+               () => maySeeResult(authUser, tournament, round, result),
+               [authUser, result, round, tournament],
+       );
+
+       return <Modal className="result-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('results.details')}
+                       </Modal.Title>
+               </Modal.Header>
+               <Modal.Body>
+                       <Row>
+                               <Form.Group as={Col} sm={6}>
+                                       <Form.Label>{t('results.round')}</Form.Label>
+                                       <div>
+                                               #{round.number || '?'}
+                                               {' '}
+                                               {t('rounds.date', { date: new Date(round.created_at) })}
+                                       </div>
+                               </Form.Group>
+                               <Form.Group as={Col} sm={6}>
+                                       <Form.Label>{t('results.runner')}</Form.Label>
+                                       <div><Box user={user} /></div>
+                               </Form.Group>
+                               <Form.Group as={Col} sm={6}>
+                                       <Form.Label>{t('results.result')}</Form.Label>
+                                       <div>
+                                               {maySee && result && result.has_finished
+                                                       ? getTime(result, maySee)
+                                                       : t('results.pending')}
+                                       </div>
+                               </Form.Group>
+                               <Form.Group as={Col} sm={6}>
+                                       <Form.Label>{t('results.placement')}</Form.Label>
+                                       <div>
+                                               {maySee && result && result.placement
+                                                       ? getPlacement(result, t)
+                                                       : t('results.pending')}
+                                       </div>
+                               </Form.Group>
+                               {maySee && result && result.comment ?
+                                       <Form.Group as={Col} sm={12}>
+                                               <Form.Label>{t('results.comment')}</Form.Label>
+                                               <div>{result.comment}</div>
+                                       </Form.Group>
+                               : null}
+                       </Row>
+               </Modal.Body>
+               <Modal.Footer>
+                       <Button onClick={onHide} variant="secondary">
+                               {t('button.close')}
+                       </Button>
+               </Modal.Footer>
+       </Modal>;
+};
+
+DetailDialog.propTypes = {
+       onHide: PropTypes.func,
+       round: PropTypes.shape({
+               created_at: PropTypes.string,
+               number: PropTypes.number,
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+       user: PropTypes.shape({
+       }),
+};
+
+export default DetailDialog;
diff --git a/resources/js/components/results/Item.js b/resources/js/components/results/Item.js
deleted file mode 100644 (file)
index a95bffe..0000000
+++ /dev/null
@@ -1,116 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import DetailDialog from './DetailDialog';
-import Icon from '../common/Icon';
-import Box from '../users/Box';
-import { getIcon, getTime } from '../../helpers/Result';
-import { maySeeResult } from '../../helpers/permissions';
-import { findResult } from '../../helpers/User';
-import { useUser } from '../../hooks/user';
-
-const getClassName = result => {
-       const classNames = ['status'];
-       if (result && result.has_finished) {
-               classNames.push('finished');
-               if (result.comment) {
-                       classNames.push('has-comment');
-               }
-       } else {
-               classNames.push('pending');
-       }
-       return classNames.join(' ');
-};
-
-const twitchReg = /^https?:\/\/(www\.)?twitch\.tv/;
-const youtubeReg = /^https?:\/\/(www\.)?youtu(\.be|be\.)/;
-
-const getVoDVariant = result => {
-       if (!result || !result.vod) return 'outline-secondary';
-       if (twitchReg.test(result.vod)) {
-               return 'twitch';
-       }
-       if (youtubeReg.test(result.vod)) {
-               return 'outline-youtube';
-       }
-       return 'outline-secondary';
-};
-
-const getVoDIcon = result => {
-       const variant = getVoDVariant(result);
-       if (variant === 'twitch') {
-               return <Icon.TWITCH title="" />;
-       }
-       if (variant === 'outline-youtube') {
-               return <Icon.YOUTUBE title="" />;
-       }
-       return <Icon.VIDEO title="" />;
-};
-
-const Item = ({
-       round,
-       tournament,
-       user,
-}) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       const { t } = useTranslation();
-       const { user: authUser } = useUser();
-
-       const result = React.useMemo(
-               () => findResult(user, round),
-               [round, user],
-       );
-       const maySee = React.useMemo(
-               () => maySeeResult(authUser, tournament, round, result),
-               [authUser, result, round, tournament],
-       );
-
-       return <div className="result">
-               <Box user={user} />
-               <div className="d-flex align-items-center justify-content-between">
-                       <Button
-                               className={getClassName(result)}
-                               onClick={() => setShowDialog(true)}
-                               title={maySee && result && result.comment ? result.comment : null}
-                       >
-                               <span className="time">
-                                       {getTime(result, maySee)}
-                               </span>
-                               {getIcon(result, maySee)}
-                       </Button>
-                       {maySee && result && result.vod ?
-                               <Button
-                                       className="vod-link"
-                                       href={result.vod}
-                                       size="sm"
-                                       target="_blank"
-                                       title={t('results.vod')}
-                                       variant={getVoDVariant(result)}
-                               >
-                                       {getVoDIcon(result)}
-                               </Button>
-                       : null}
-               </div>
-               <DetailDialog
-                       onHide={() => setShowDialog(false)}
-                       round={round}
-                       show={showDialog}
-                       tournament={tournament}
-                       user={user}
-               />
-       </div>;
-};
-
-Item.propTypes = {
-       round: PropTypes.shape({
-       }),
-       tournament: PropTypes.shape({
-       }),
-       user: PropTypes.shape({
-       }),
-};
-
-export default Item;
diff --git a/resources/js/components/results/Item.jsx b/resources/js/components/results/Item.jsx
new file mode 100644 (file)
index 0000000..a95bffe
--- /dev/null
@@ -0,0 +1,116 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import DetailDialog from './DetailDialog';
+import Icon from '../common/Icon';
+import Box from '../users/Box';
+import { getIcon, getTime } from '../../helpers/Result';
+import { maySeeResult } from '../../helpers/permissions';
+import { findResult } from '../../helpers/User';
+import { useUser } from '../../hooks/user';
+
+const getClassName = result => {
+       const classNames = ['status'];
+       if (result && result.has_finished) {
+               classNames.push('finished');
+               if (result.comment) {
+                       classNames.push('has-comment');
+               }
+       } else {
+               classNames.push('pending');
+       }
+       return classNames.join(' ');
+};
+
+const twitchReg = /^https?:\/\/(www\.)?twitch\.tv/;
+const youtubeReg = /^https?:\/\/(www\.)?youtu(\.be|be\.)/;
+
+const getVoDVariant = result => {
+       if (!result || !result.vod) return 'outline-secondary';
+       if (twitchReg.test(result.vod)) {
+               return 'twitch';
+       }
+       if (youtubeReg.test(result.vod)) {
+               return 'outline-youtube';
+       }
+       return 'outline-secondary';
+};
+
+const getVoDIcon = result => {
+       const variant = getVoDVariant(result);
+       if (variant === 'twitch') {
+               return <Icon.TWITCH title="" />;
+       }
+       if (variant === 'outline-youtube') {
+               return <Icon.YOUTUBE title="" />;
+       }
+       return <Icon.VIDEO title="" />;
+};
+
+const Item = ({
+       round,
+       tournament,
+       user,
+}) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       const { t } = useTranslation();
+       const { user: authUser } = useUser();
+
+       const result = React.useMemo(
+               () => findResult(user, round),
+               [round, user],
+       );
+       const maySee = React.useMemo(
+               () => maySeeResult(authUser, tournament, round, result),
+               [authUser, result, round, tournament],
+       );
+
+       return <div className="result">
+               <Box user={user} />
+               <div className="d-flex align-items-center justify-content-between">
+                       <Button
+                               className={getClassName(result)}
+                               onClick={() => setShowDialog(true)}
+                               title={maySee && result && result.comment ? result.comment : null}
+                       >
+                               <span className="time">
+                                       {getTime(result, maySee)}
+                               </span>
+                               {getIcon(result, maySee)}
+                       </Button>
+                       {maySee && result && result.vod ?
+                               <Button
+                                       className="vod-link"
+                                       href={result.vod}
+                                       size="sm"
+                                       target="_blank"
+                                       title={t('results.vod')}
+                                       variant={getVoDVariant(result)}
+                               >
+                                       {getVoDIcon(result)}
+                               </Button>
+                       : null}
+               </div>
+               <DetailDialog
+                       onHide={() => setShowDialog(false)}
+                       round={round}
+                       show={showDialog}
+                       tournament={tournament}
+                       user={user}
+               />
+       </div>;
+};
+
+Item.propTypes = {
+       round: PropTypes.shape({
+       }),
+       tournament: PropTypes.shape({
+       }),
+       user: PropTypes.shape({
+       }),
+};
+
+export default Item;
diff --git a/resources/js/components/results/List.js b/resources/js/components/results/List.js
deleted file mode 100644 (file)
index f26a131..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import Item from './Item';
-import { sortByFinished, sortByResult } from '../../helpers/Participant';
-import { maySeeResults } from '../../helpers/permissions';
-import { getRunners } from '../../helpers/Tournament';
-import { sortByTime, sortByUsername } from '../../helpers/Result';
-import { useUser } from '../../hooks/user';
-
-const List = ({ round, tournament }) => {
-       const { user } = useUser();
-
-       if (tournament.type === 'open-async') {
-               const results = maySeeResults(user, tournament, round)
-                       ? sortByTime(round.results || [])
-                       : sortByUsername(round.results || []);
-               return <div className="results d-flex flex-wrap">
-                       {results.map(result =>
-                               <Item
-                                       key={result.id}
-                                       round={round}
-                                       tournament={tournament}
-                                       user={result.user}
-                               />
-                       )}
-               </div>;
-       }
-       const runners = maySeeResults(user, tournament, round)
-               ? sortByResult(getRunners(tournament), round)
-               : sortByFinished(getRunners(tournament), round);
-       return <div className="results d-flex flex-wrap">
-               {runners.map(participant =>
-                       <Item
-                               key={participant.id}
-                               round={round}
-                               tournament={tournament}
-                               user={participant.user}
-                       />
-               )}
-       </div>;
-};
-
-List.propTypes = {
-       round: PropTypes.shape({
-               results: PropTypes.arrayOf(PropTypes.shape({
-               })),
-       }),
-       tournament: PropTypes.shape({
-               participants: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               type: PropTypes.string,
-               users: PropTypes.arrayOf(PropTypes.shape({
-               })),
-       }),
-};
-
-export default List;
diff --git a/resources/js/components/results/List.jsx b/resources/js/components/results/List.jsx
new file mode 100644 (file)
index 0000000..f26a131
--- /dev/null
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Item from './Item';
+import { sortByFinished, sortByResult } from '../../helpers/Participant';
+import { maySeeResults } from '../../helpers/permissions';
+import { getRunners } from '../../helpers/Tournament';
+import { sortByTime, sortByUsername } from '../../helpers/Result';
+import { useUser } from '../../hooks/user';
+
+const List = ({ round, tournament }) => {
+       const { user } = useUser();
+
+       if (tournament.type === 'open-async') {
+               const results = maySeeResults(user, tournament, round)
+                       ? sortByTime(round.results || [])
+                       : sortByUsername(round.results || []);
+               return <div className="results d-flex flex-wrap">
+                       {results.map(result =>
+                               <Item
+                                       key={result.id}
+                                       round={round}
+                                       tournament={tournament}
+                                       user={result.user}
+                               />
+                       )}
+               </div>;
+       }
+       const runners = maySeeResults(user, tournament, round)
+               ? sortByResult(getRunners(tournament), round)
+               : sortByFinished(getRunners(tournament), round);
+       return <div className="results d-flex flex-wrap">
+               {runners.map(participant =>
+                       <Item
+                               key={participant.id}
+                               round={round}
+                               tournament={tournament}
+                               user={participant.user}
+                       />
+               )}
+       </div>;
+};
+
+List.propTypes = {
+       round: PropTypes.shape({
+               results: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+       tournament: PropTypes.shape({
+               participants: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               type: PropTypes.string,
+               users: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+};
+
+export default List;
diff --git a/resources/js/components/results/ReportButton.js b/resources/js/components/results/ReportButton.js
deleted file mode 100644 (file)
index 9db944e..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import ReportDialog from './ReportDialog';
-import Icon from '../common/Icon';
-import { findResult } from '../../helpers/User';
-import i18n from '../../i18n';
-
-const getButtonLabel = (user, round) => {
-       const result = findResult(user, round);
-       if (round.locked) {
-               if (result && result.comment) {
-                       return i18n.t('results.editComment');
-               } else {
-                       return i18n.t('results.addComment');
-               }
-       } else {
-               if (result && (result.time || result.forfeit)) {
-                       return i18n.t('results.edit');
-               } else {
-                       return i18n.t('results.report');
-               }
-       }
-};
-
-const ReportButton = ({ round, user }) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       if (round.locked && !findResult(user, round)) {
-               return null;
-       }
-
-       return <>
-               <Button
-                       onClick={() => setShowDialog(true)}
-                       variant="secondary"
-               >
-                       {getButtonLabel(user, round)}
-                       {' '}
-                       <Icon.EDIT title="" />
-               </Button>
-               <ReportDialog
-                       onHide={() => setShowDialog(false)}
-                       round={round}
-                       show={showDialog}
-                       user={user}
-               />
-       </>;
-};
-
-ReportButton.propTypes = {
-       round: PropTypes.shape({
-               locked: PropTypes.bool,
-       }),
-       tournament: PropTypes.shape({
-       }),
-       user: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(ReportButton);
diff --git a/resources/js/components/results/ReportButton.jsx b/resources/js/components/results/ReportButton.jsx
new file mode 100644 (file)
index 0000000..9db944e
--- /dev/null
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import ReportDialog from './ReportDialog';
+import Icon from '../common/Icon';
+import { findResult } from '../../helpers/User';
+import i18n from '../../i18n';
+
+const getButtonLabel = (user, round) => {
+       const result = findResult(user, round);
+       if (round.locked) {
+               if (result && result.comment) {
+                       return i18n.t('results.editComment');
+               } else {
+                       return i18n.t('results.addComment');
+               }
+       } else {
+               if (result && (result.time || result.forfeit)) {
+                       return i18n.t('results.edit');
+               } else {
+                       return i18n.t('results.report');
+               }
+       }
+};
+
+const ReportButton = ({ round, user }) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       if (round.locked && !findResult(user, round)) {
+               return null;
+       }
+
+       return <>
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       variant="secondary"
+               >
+                       {getButtonLabel(user, round)}
+                       {' '}
+                       <Icon.EDIT title="" />
+               </Button>
+               <ReportDialog
+                       onHide={() => setShowDialog(false)}
+                       round={round}
+                       show={showDialog}
+                       user={user}
+               />
+       </>;
+};
+
+ReportButton.propTypes = {
+       round: PropTypes.shape({
+               locked: PropTypes.bool,
+       }),
+       tournament: PropTypes.shape({
+       }),
+       user: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(ReportButton);
diff --git a/resources/js/components/results/ReportDialog.js b/resources/js/components/results/ReportDialog.js
deleted file mode 100644 (file)
index b3e5282..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import ReportForm from './ReportForm';
-import i18n from '../../i18n';
-
-const ReportDialog = ({
-       onHide,
-       round,
-       show,
-       user,
-}) =>
-<Modal className="report-dialog" onHide={onHide} show={show}>
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t('results.report')}
-               </Modal.Title>
-       </Modal.Header>
-       <ReportForm
-               onCancel={onHide}
-               round={round}
-               user={user}
-       />
-</Modal>;
-
-ReportDialog.propTypes = {
-       onHide: PropTypes.func,
-       round: PropTypes.shape({
-       }),
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-       }),
-       user: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(ReportDialog);
diff --git a/resources/js/components/results/ReportDialog.jsx b/resources/js/components/results/ReportDialog.jsx
new file mode 100644 (file)
index 0000000..b3e5282
--- /dev/null
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import ReportForm from './ReportForm';
+import i18n from '../../i18n';
+
+const ReportDialog = ({
+       onHide,
+       round,
+       show,
+       user,
+}) =>
+<Modal className="report-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('results.report')}
+               </Modal.Title>
+       </Modal.Header>
+       <ReportForm
+               onCancel={onHide}
+               round={round}
+               user={user}
+       />
+</Modal>;
+
+ReportDialog.propTypes = {
+       onHide: PropTypes.func,
+       round: PropTypes.shape({
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+       user: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(ReportDialog);
diff --git a/resources/js/components/results/ReportForm.js b/resources/js/components/results/ReportForm.js
deleted file mode 100644 (file)
index 2e85f4a..0000000
+++ /dev/null
@@ -1,190 +0,0 @@
-import axios from 'axios';
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import LargeCheck from '../common/LargeCheck';
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import { findResult } from '../../helpers/User';
-import { formatTime, parseTime } from '../../helpers/Result';
-import i18n from '../../i18n';
-import yup from '../../schema/yup';
-
-const ReportForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       round,
-       touched,
-       values,
-}) =>
-<Form noValidate onSubmit={handleSubmit}>
-       <Modal.Body>
-               {!round.locked ?
-                       <Row>
-                               <Form.Group as={Col} sm={9} controlId="report.time">
-                                       <Form.Label>{i18n.t('results.reportTime')}</Form.Label>
-                                       <Form.Control
-                                               isInvalid={!!(touched.time && errors.time)}
-                                               name="time"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               placeholder={values.forfeit ? 'DNF' : '1:22:59'}
-                                               type="text"
-                                               value={values.time || ''}
-                                       />
-                                       {touched.time && errors.time ?
-                                               <Form.Control.Feedback type="invalid">
-                                                       {i18n.t(errors.time)}
-                                               </Form.Control.Feedback>
-                                       :
-                                               <Form.Text muted>
-                                                       {parseTime(values.time) ?
-                                                               i18n.t(
-                                                                       'results.reportPreview',
-                                                                       { time: formatTime({ time: parseTime(values.time) })},
-                                                               )
-                                                       : null}
-                                               </Form.Text>
-                                       }
-                               </Form.Group>
-                               <Form.Group as={Col} sm={3} controlId="report.forfeit">
-                                       <Form.Label>{i18n.t('results.forfeit')}</Form.Label>
-                                       <Form.Control
-                                               as={LargeCheck}
-                                               isInvalid={!!(touched.forfeit && errors.forfeit)}
-                                               name="forfeit"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               value={!!values.forfeit}
-                                       />
-                               </Form.Group>
-                       </Row>
-               : null}
-               <Form.Group controlId="report.vod">
-                       <Form.Label>{i18n.t('results.vod')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.vod && errors.vod)}
-                               name="vod"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               placeholder="https://twitch.tv/youtube"
-                               type="text"
-                               value={values.vod || ''}
-                       />
-                       {touched.vod && errors.vod ?
-                               <Form.Control.Feedback type="invalid">
-                                       {i18n.t(errors.vod)}
-                               </Form.Control.Feedback>
-                       :
-                               <Form.Text muted>
-                                       {i18n.t('results.vodNote')}
-                               </Form.Text>
-                       }
-               </Form.Group>
-               <Form.Group controlId="report.comment">
-                       <Form.Label>{i18n.t('results.comment')}</Form.Label>
-                       <Form.Control
-                               as="textarea"
-                               isInvalid={!!(touched.comment && errors.comment)}
-                               name="comment"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               rows="6"
-                               value={values.comment || ''}
-                       />
-               </Form.Group>
-       </Modal.Body>
-       <Modal.Footer>
-               {onCancel ?
-                       <Button onClick={onCancel} variant="secondary">
-                               {i18n.t('button.cancel')}
-                       </Button>
-               : null}
-               <Button type="submit" variant="primary">
-                       {i18n.t('button.save')}
-               </Button>
-       </Modal.Footer>
-</Form>;
-
-ReportForm.propTypes = {
-       errors: PropTypes.shape({
-               comment: PropTypes.string,
-               forfeit: PropTypes.string,
-               time: PropTypes.string,
-               vod: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       round: PropTypes.shape({
-               locked: PropTypes.bool,
-       }),
-       touched: PropTypes.shape({
-               comment: PropTypes.bool,
-               forfeit: PropTypes.bool,
-               time: PropTypes.bool,
-               vod: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               comment: PropTypes.string,
-               forfeit: PropTypes.bool,
-               time: PropTypes.string,
-               vod: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'ReportForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { comment, forfeit, round_id, time, user_id, vod } = values;
-               const { setErrors } = actions;
-               const { onCancel } = actions.props;
-               try {
-                       await axios.post('/api/results', {
-                               comment,
-                               forfeit,
-                               round_id,
-                               time: parseTime(time) || 0,
-                               user_id,
-                               vod,
-                       });
-                       toastr.success(i18n.t('results.reportSuccess'));
-                       if (onCancel) {
-                               onCancel();
-                       }
-               } catch (e) {
-                       toastr.error(i18n.t('results.reportError'));
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ round, user }) => {
-               const result = findResult(user, round);
-               return {
-                       comment: result && result.comment ? result.comment : '',
-                       forfeit: result ? !!result.forfeit : false,
-                       round_id: round.id,
-                       time: result && result.time ? formatTime(result) : '',
-                       user_id: user.id,
-                       vod: result && result.vod ? result.vod : '',
-               };
-       },
-       validationSchema: yup.object().shape({
-               comment: yup.string(),
-               forfeit: yup.boolean().required(),
-               time: yup.string().time().when('forfeit', {
-                       is: false,
-                       then: () => yup.string().required().time(),
-               }),
-               vod: yup.string().url(),
-       }),
-})(withTranslation()(ReportForm));
diff --git a/resources/js/components/results/ReportForm.jsx b/resources/js/components/results/ReportForm.jsx
new file mode 100644 (file)
index 0000000..2e85f4a
--- /dev/null
@@ -0,0 +1,190 @@
+import axios from 'axios';
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import LargeCheck from '../common/LargeCheck';
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import { findResult } from '../../helpers/User';
+import { formatTime, parseTime } from '../../helpers/Result';
+import i18n from '../../i18n';
+import yup from '../../schema/yup';
+
+const ReportForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       round,
+       touched,
+       values,
+}) =>
+<Form noValidate onSubmit={handleSubmit}>
+       <Modal.Body>
+               {!round.locked ?
+                       <Row>
+                               <Form.Group as={Col} sm={9} controlId="report.time">
+                                       <Form.Label>{i18n.t('results.reportTime')}</Form.Label>
+                                       <Form.Control
+                                               isInvalid={!!(touched.time && errors.time)}
+                                               name="time"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               placeholder={values.forfeit ? 'DNF' : '1:22:59'}
+                                               type="text"
+                                               value={values.time || ''}
+                                       />
+                                       {touched.time && errors.time ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {i18n.t(errors.time)}
+                                               </Form.Control.Feedback>
+                                       :
+                                               <Form.Text muted>
+                                                       {parseTime(values.time) ?
+                                                               i18n.t(
+                                                                       'results.reportPreview',
+                                                                       { time: formatTime({ time: parseTime(values.time) })},
+                                                               )
+                                                       : null}
+                                               </Form.Text>
+                                       }
+                               </Form.Group>
+                               <Form.Group as={Col} sm={3} controlId="report.forfeit">
+                                       <Form.Label>{i18n.t('results.forfeit')}</Form.Label>
+                                       <Form.Control
+                                               as={LargeCheck}
+                                               isInvalid={!!(touched.forfeit && errors.forfeit)}
+                                               name="forfeit"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               value={!!values.forfeit}
+                                       />
+                               </Form.Group>
+                       </Row>
+               : null}
+               <Form.Group controlId="report.vod">
+                       <Form.Label>{i18n.t('results.vod')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.vod && errors.vod)}
+                               name="vod"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               placeholder="https://twitch.tv/youtube"
+                               type="text"
+                               value={values.vod || ''}
+                       />
+                       {touched.vod && errors.vod ?
+                               <Form.Control.Feedback type="invalid">
+                                       {i18n.t(errors.vod)}
+                               </Form.Control.Feedback>
+                       :
+                               <Form.Text muted>
+                                       {i18n.t('results.vodNote')}
+                               </Form.Text>
+                       }
+               </Form.Group>
+               <Form.Group controlId="report.comment">
+                       <Form.Label>{i18n.t('results.comment')}</Form.Label>
+                       <Form.Control
+                               as="textarea"
+                               isInvalid={!!(touched.comment && errors.comment)}
+                               name="comment"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               rows="6"
+                               value={values.comment || ''}
+                       />
+               </Form.Group>
+       </Modal.Body>
+       <Modal.Footer>
+               {onCancel ?
+                       <Button onClick={onCancel} variant="secondary">
+                               {i18n.t('button.cancel')}
+                       </Button>
+               : null}
+               <Button type="submit" variant="primary">
+                       {i18n.t('button.save')}
+               </Button>
+       </Modal.Footer>
+</Form>;
+
+ReportForm.propTypes = {
+       errors: PropTypes.shape({
+               comment: PropTypes.string,
+               forfeit: PropTypes.string,
+               time: PropTypes.string,
+               vod: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       round: PropTypes.shape({
+               locked: PropTypes.bool,
+       }),
+       touched: PropTypes.shape({
+               comment: PropTypes.bool,
+               forfeit: PropTypes.bool,
+               time: PropTypes.bool,
+               vod: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               comment: PropTypes.string,
+               forfeit: PropTypes.bool,
+               time: PropTypes.string,
+               vod: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'ReportForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { comment, forfeit, round_id, time, user_id, vod } = values;
+               const { setErrors } = actions;
+               const { onCancel } = actions.props;
+               try {
+                       await axios.post('/api/results', {
+                               comment,
+                               forfeit,
+                               round_id,
+                               time: parseTime(time) || 0,
+                               user_id,
+                               vod,
+                       });
+                       toastr.success(i18n.t('results.reportSuccess'));
+                       if (onCancel) {
+                               onCancel();
+                       }
+               } catch (e) {
+                       toastr.error(i18n.t('results.reportError'));
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ round, user }) => {
+               const result = findResult(user, round);
+               return {
+                       comment: result && result.comment ? result.comment : '',
+                       forfeit: result ? !!result.forfeit : false,
+                       round_id: round.id,
+                       time: result && result.time ? formatTime(result) : '',
+                       user_id: user.id,
+                       vod: result && result.vod ? result.vod : '',
+               };
+       },
+       validationSchema: yup.object().shape({
+               comment: yup.string(),
+               forfeit: yup.boolean().required(),
+               time: yup.string().time().when('forfeit', {
+                       is: false,
+                       then: () => yup.string().required().time(),
+               }),
+               vod: yup.string().url(),
+       }),
+})(withTranslation()(ReportForm));
diff --git a/resources/js/components/rounds/DeleteButton.js b/resources/js/components/rounds/DeleteButton.js
deleted file mode 100644 (file)
index 578380d..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import DeleteDialog from './DeleteDialog';
-import Icon from '../common/Icon';
-
-const DeleteButton = ({
-       round,
-       tournament,
-}) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       const { t } = useTranslation();
-
-       return <>
-               <DeleteDialog
-                       onHide={() => setShowDialog(false)}
-                       round={round}
-                       show={showDialog}
-                       tournament={tournament}
-               />
-               <Button
-                       onClick={() => setShowDialog(true)}
-                       size="sm"
-                       title={t('rounds.delete')}
-                       variant="outline-danger"
-               >
-                       <Icon.REMOVE title="" />
-               </Button>
-       </>;
-};
-
-DeleteButton.propTypes = {
-       round: PropTypes.shape({
-               locked: PropTypes.bool,
-       }),
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default DeleteButton;
diff --git a/resources/js/components/rounds/DeleteButton.jsx b/resources/js/components/rounds/DeleteButton.jsx
new file mode 100644 (file)
index 0000000..578380d
--- /dev/null
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import DeleteDialog from './DeleteDialog';
+import Icon from '../common/Icon';
+
+const DeleteButton = ({
+       round,
+       tournament,
+}) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       const { t } = useTranslation();
+
+       return <>
+               <DeleteDialog
+                       onHide={() => setShowDialog(false)}
+                       round={round}
+                       show={showDialog}
+                       tournament={tournament}
+               />
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       size="sm"
+                       title={t('rounds.delete')}
+                       variant="outline-danger"
+               >
+                       <Icon.REMOVE title="" />
+               </Button>
+       </>;
+};
+
+DeleteButton.propTypes = {
+       round: PropTypes.shape({
+               locked: PropTypes.bool,
+       }),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default DeleteButton;
diff --git a/resources/js/components/rounds/DeleteDialog.js b/resources/js/components/rounds/DeleteDialog.js
deleted file mode 100644 (file)
index 188f082..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button, Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-const EditDialog = ({
-       onHide,
-       round,
-       show,
-}) => {
-       const { t } = useTranslation();
-
-       const handleDelete = React.useCallback(async() => {
-               try {
-                       await axios.delete(`/api/rounds/${round.id}`);
-                       onHide();
-               } catch (e) {
-                       toastr.error(t('rounds.deleteError'));
-               }
-       }, [onHide, round, t]);
-
-       return <Modal className="edit-dialog" onHide={onHide} show={show}>
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('rounds.delete')}
-                       </Modal.Title>
-               </Modal.Header>
-               <Modal.Body>
-                       <Alert variant="danger">
-                               {t('rounds.deleteConfirmMessage', {
-                                       ...round,
-                                       date: new Date(round.created_at),
-                               })}
-                       </Alert>
-               </Modal.Body>
-               <Modal.Footer>
-                       <Button onClick={onHide} variant="secondary">
-                               {t('button.cancel')}
-                       </Button>
-                       <Button onClick={handleDelete} variant="danger">
-                               {t('button.delete')}
-                       </Button>
-               </Modal.Footer>
-       </Modal>;
-};
-
-EditDialog.propTypes = {
-       onHide: PropTypes.func,
-       round: PropTypes.shape({
-               created_at: PropTypes.string,
-               id: PropTypes.number,
-       }),
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default EditDialog;
diff --git a/resources/js/components/rounds/DeleteDialog.jsx b/resources/js/components/rounds/DeleteDialog.jsx
new file mode 100644 (file)
index 0000000..188f082
--- /dev/null
@@ -0,0 +1,60 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+const EditDialog = ({
+       onHide,
+       round,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       const handleDelete = React.useCallback(async() => {
+               try {
+                       await axios.delete(`/api/rounds/${round.id}`);
+                       onHide();
+               } catch (e) {
+                       toastr.error(t('rounds.deleteError'));
+               }
+       }, [onHide, round, t]);
+
+       return <Modal className="edit-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('rounds.delete')}
+                       </Modal.Title>
+               </Modal.Header>
+               <Modal.Body>
+                       <Alert variant="danger">
+                               {t('rounds.deleteConfirmMessage', {
+                                       ...round,
+                                       date: new Date(round.created_at),
+                               })}
+                       </Alert>
+               </Modal.Body>
+               <Modal.Footer>
+                       <Button onClick={onHide} variant="secondary">
+                               {t('button.cancel')}
+                       </Button>
+                       <Button onClick={handleDelete} variant="danger">
+                               {t('button.delete')}
+                       </Button>
+               </Modal.Footer>
+       </Modal>;
+};
+
+EditDialog.propTypes = {
+       onHide: PropTypes.func,
+       round: PropTypes.shape({
+               created_at: PropTypes.string,
+               id: PropTypes.number,
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default EditDialog;
diff --git a/resources/js/components/rounds/EditButton.js b/resources/js/components/rounds/EditButton.js
deleted file mode 100644 (file)
index d32b843..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import EditDialog from './EditDialog';
-import Icon from '../common/Icon';
-
-const EditButton = ({
-       round,
-       tournament,
-}) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       const { t } = useTranslation();
-
-       return <>
-               <EditDialog
-                       onHide={() => setShowDialog(false)}
-                       round={round}
-                       show={showDialog}
-                       tournament={tournament}
-               />
-               <Button
-                       onClick={() => setShowDialog(true)}
-                       size="sm"
-                       title={t('rounds.edit')}
-                       variant="outline-secondary"
-               >
-                       <Icon.EDIT title="" />
-               </Button>
-       </>;
-};
-
-EditButton.propTypes = {
-       round: PropTypes.shape({
-               locked: PropTypes.bool,
-       }),
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default EditButton;
diff --git a/resources/js/components/rounds/EditButton.jsx b/resources/js/components/rounds/EditButton.jsx
new file mode 100644 (file)
index 0000000..d32b843
--- /dev/null
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import EditDialog from './EditDialog';
+import Icon from '../common/Icon';
+
+const EditButton = ({
+       round,
+       tournament,
+}) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       const { t } = useTranslation();
+
+       return <>
+               <EditDialog
+                       onHide={() => setShowDialog(false)}
+                       round={round}
+                       show={showDialog}
+                       tournament={tournament}
+               />
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       size="sm"
+                       title={t('rounds.edit')}
+                       variant="outline-secondary"
+               >
+                       <Icon.EDIT title="" />
+               </Button>
+       </>;
+};
+
+EditButton.propTypes = {
+       round: PropTypes.shape({
+               locked: PropTypes.bool,
+       }),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default EditButton;
diff --git a/resources/js/components/rounds/EditDialog.js b/resources/js/components/rounds/EditDialog.js
deleted file mode 100644 (file)
index bf72c32..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import EditForm from './EditForm';
-
-const EditDialog = ({
-       onHide,
-       round,
-       show,
-}) => {
-       const { t } = useTranslation();
-
-       return <Modal className="edit-dialog" onHide={onHide} show={show}>
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('rounds.edit')}
-                       </Modal.Title>
-               </Modal.Header>
-               <EditForm
-                       onCancel={onHide}
-                       round={round}
-               />
-       </Modal>;
-};
-
-EditDialog.propTypes = {
-       onHide: PropTypes.func,
-       round: PropTypes.shape({
-       }),
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default EditDialog;
diff --git a/resources/js/components/rounds/EditDialog.jsx b/resources/js/components/rounds/EditDialog.jsx
new file mode 100644 (file)
index 0000000..bf72c32
--- /dev/null
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import EditForm from './EditForm';
+
+const EditDialog = ({
+       onHide,
+       round,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal className="edit-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('rounds.edit')}
+                       </Modal.Title>
+               </Modal.Header>
+               <EditForm
+                       onCancel={onHide}
+                       round={round}
+               />
+       </Modal>;
+};
+
+EditDialog.propTypes = {
+       onHide: PropTypes.func,
+       round: PropTypes.shape({
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default EditDialog;
diff --git a/resources/js/components/rounds/EditForm.js b/resources/js/components/rounds/EditForm.js
deleted file mode 100644 (file)
index ac2cd42..0000000
+++ /dev/null
@@ -1,195 +0,0 @@
-import axios from 'axios';
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import SeedCodeInput from './SeedCodeInput';
-import UserSelect from '../common/UserSelect';
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import i18n from '../../i18n';
-import yup from '../../schema/yup';
-
-const EditForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       touched,
-       values,
-}) =>
-<Form noValidate onSubmit={handleSubmit}>
-       <Modal.Body>
-               <Row>
-                       <Form.Group as={Col} controlId="round.title">
-                               <Form.Label>{i18n.t('rounds.title')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.title && errors.title)}
-                                       name="title"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="text"
-                                       value={values.title || ''}
-                               />
-                               {touched.title && errors.title ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {i18n.t(errors.title)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-               <Row>
-                       <Form.Group as={Col} controlId="round.seed">
-                               <Form.Label>{i18n.t('rounds.seed')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.seed && errors.seed)}
-                                       name="seed"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="text"
-                                       value={values.seed || ''}
-                               />
-                               {touched.seed && errors.seed ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {i18n.t(errors.seed)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-               <Row>
-                       <Form.Group as={Col} controlId="round.spoiler">
-                               <Form.Label>{i18n.t('rounds.spoiler')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.spoiler && errors.spoiler)}
-                                       name="spoiler"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="text"
-                                       value={values.spoiler || ''}
-                               />
-                               {touched.spoiler && errors.spoiler ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {i18n.t(errors.spoiler)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-               <Row>
-                       <Form.Group as={Col}>
-                               <Form.Label>{i18n.t('rounds.code')}</Form.Label>
-                               <Form.Control
-                                       as={SeedCodeInput}
-                                       game={values.game || 'mixed'}
-                                       isInvalid={!!(touched.code && errors.code)}
-                                       name="code"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.code || []}
-                               />
-                               {touched.code && errors.code ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {i18n.t(errors.code)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-               <Row>
-                       <Form.Group as={Col}>
-                               <Form.Label>{i18n.t('rounds.rolled_by')}</Form.Label>
-                               <Form.Control
-                                       as={UserSelect}
-                                       isInvalid={!!(touched.rolled_by && errors.rolled_by)}
-                                       name="rolled_by"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.rolled_by || null}
-                               />
-                               {touched.rolled_by && errors.rolled_by ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {i18n.t(errors.rolled_by)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-       </Modal.Body>
-       <Modal.Footer>
-               {onCancel ?
-                       <Button onClick={onCancel} variant="secondary">
-                               {i18n.t('button.cancel')}
-                       </Button>
-               : null}
-               <Button type="submit" variant="primary">
-                       {i18n.t('button.save')}
-               </Button>
-       </Modal.Footer>
-</Form>;
-
-EditForm.propTypes = {
-       errors: PropTypes.shape({
-               code: PropTypes.arrayOf(PropTypes.string),
-               rolled_by: PropTypes.string,
-               seed: PropTypes.string,
-               spoiler: PropTypes.string,
-               title: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               code: PropTypes.arrayOf(PropTypes.bool),
-               rolled_by: PropTypes.bool,
-               seed: PropTypes.bool,
-               spoiler: PropTypes.bool,
-               title: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               code: PropTypes.arrayOf(PropTypes.string),
-               game: PropTypes.string,
-               rolled_by: PropTypes.string,
-               seed: PropTypes.string,
-               spoiler: PropTypes.string,
-               title: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'EditForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { round_id } = values;
-               const { setErrors } = actions;
-               const { onCancel } = actions.props;
-               try {
-                       await axios.put(`/api/rounds/${round_id}`, values);
-                       toastr.success(i18n.t('rounds.editSuccess'));
-                       if (onCancel) {
-                               onCancel();
-                       }
-               } catch (e) {
-                       toastr.error(i18n.t('rounds.editError'));
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ round }) => ({
-               code: round.code || [],
-               game: round.game || 'mixed',
-               rolled_by: round.rolled_by || null,
-               round_id: round.id,
-               seed: round.seed || '',
-               spoiler: round.spoiler || '',
-               title: round.title || '',
-       }),
-       validationSchema: yup.object().shape({
-               code: yup.array().of(yup.string()),
-               rolled_by: yup.string().nullable(),
-               seed: yup.string().url(),
-               spoiler: yup.string().url(),
-               title: yup.string(),
-       }),
-})(withTranslation()(EditForm));
diff --git a/resources/js/components/rounds/EditForm.jsx b/resources/js/components/rounds/EditForm.jsx
new file mode 100644 (file)
index 0000000..ac2cd42
--- /dev/null
@@ -0,0 +1,195 @@
+import axios from 'axios';
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import SeedCodeInput from './SeedCodeInput';
+import UserSelect from '../common/UserSelect';
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import i18n from '../../i18n';
+import yup from '../../schema/yup';
+
+const EditForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       values,
+}) =>
+<Form noValidate onSubmit={handleSubmit}>
+       <Modal.Body>
+               <Row>
+                       <Form.Group as={Col} controlId="round.title">
+                               <Form.Label>{i18n.t('rounds.title')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.title && errors.title)}
+                                       name="title"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.title || ''}
+                               />
+                               {touched.title && errors.title ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.title)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+               <Row>
+                       <Form.Group as={Col} controlId="round.seed">
+                               <Form.Label>{i18n.t('rounds.seed')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.seed && errors.seed)}
+                                       name="seed"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.seed || ''}
+                               />
+                               {touched.seed && errors.seed ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.seed)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+               <Row>
+                       <Form.Group as={Col} controlId="round.spoiler">
+                               <Form.Label>{i18n.t('rounds.spoiler')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.spoiler && errors.spoiler)}
+                                       name="spoiler"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.spoiler || ''}
+                               />
+                               {touched.spoiler && errors.spoiler ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.spoiler)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+               <Row>
+                       <Form.Group as={Col}>
+                               <Form.Label>{i18n.t('rounds.code')}</Form.Label>
+                               <Form.Control
+                                       as={SeedCodeInput}
+                                       game={values.game || 'mixed'}
+                                       isInvalid={!!(touched.code && errors.code)}
+                                       name="code"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.code || []}
+                               />
+                               {touched.code && errors.code ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.code)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+               <Row>
+                       <Form.Group as={Col}>
+                               <Form.Label>{i18n.t('rounds.rolled_by')}</Form.Label>
+                               <Form.Control
+                                       as={UserSelect}
+                                       isInvalid={!!(touched.rolled_by && errors.rolled_by)}
+                                       name="rolled_by"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.rolled_by || null}
+                               />
+                               {touched.rolled_by && errors.rolled_by ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.rolled_by)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+       </Modal.Body>
+       <Modal.Footer>
+               {onCancel ?
+                       <Button onClick={onCancel} variant="secondary">
+                               {i18n.t('button.cancel')}
+                       </Button>
+               : null}
+               <Button type="submit" variant="primary">
+                       {i18n.t('button.save')}
+               </Button>
+       </Modal.Footer>
+</Form>;
+
+EditForm.propTypes = {
+       errors: PropTypes.shape({
+               code: PropTypes.arrayOf(PropTypes.string),
+               rolled_by: PropTypes.string,
+               seed: PropTypes.string,
+               spoiler: PropTypes.string,
+               title: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               code: PropTypes.arrayOf(PropTypes.bool),
+               rolled_by: PropTypes.bool,
+               seed: PropTypes.bool,
+               spoiler: PropTypes.bool,
+               title: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               code: PropTypes.arrayOf(PropTypes.string),
+               game: PropTypes.string,
+               rolled_by: PropTypes.string,
+               seed: PropTypes.string,
+               spoiler: PropTypes.string,
+               title: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'EditForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { round_id } = values;
+               const { setErrors } = actions;
+               const { onCancel } = actions.props;
+               try {
+                       await axios.put(`/api/rounds/${round_id}`, values);
+                       toastr.success(i18n.t('rounds.editSuccess'));
+                       if (onCancel) {
+                               onCancel();
+                       }
+               } catch (e) {
+                       toastr.error(i18n.t('rounds.editError'));
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ round }) => ({
+               code: round.code || [],
+               game: round.game || 'mixed',
+               rolled_by: round.rolled_by || null,
+               round_id: round.id,
+               seed: round.seed || '',
+               spoiler: round.spoiler || '',
+               title: round.title || '',
+       }),
+       validationSchema: yup.object().shape({
+               code: yup.array().of(yup.string()),
+               rolled_by: yup.string().nullable(),
+               seed: yup.string().url(),
+               spoiler: yup.string().url(),
+               title: yup.string(),
+       }),
+})(withTranslation()(EditForm));
diff --git a/resources/js/components/rounds/Item.js b/resources/js/components/rounds/Item.js
deleted file mode 100644 (file)
index 9cd095f..0000000
+++ /dev/null
@@ -1,130 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import DeleteButton from './DeleteButton';
-import EditButton from './EditButton';
-import LockButton from './LockButton';
-import SeedButton from './SeedButton';
-import SeedCode from './SeedCode';
-import SeedRolledBy from './SeedRolledBy';
-import RoundProtocol from '../protocol/RoundProtocol';
-import List from '../results/List';
-import ReportButton from '../results/ReportButton';
-import {
-       mayDeleteRound,
-       mayEditRound,
-       mayReportResult,
-       mayViewProtocol,
-       isRunner,
-} from '../../helpers/permissions';
-import { isComplete } from '../../helpers/Round';
-import { hasFinishedRound } from '../../helpers/User';
-import { useUser } from '../../hooks/user';
-
-const getClassName = (round, tournament, user) => {
-       const classNames = ['round'];
-       if (round.locked) {
-               classNames.push('is-locked');
-       } else {
-               classNames.push('is-unlocked');
-       }
-       if (isComplete(tournament, round)) {
-               classNames.push('is-complete');
-       } else {
-               classNames.push('is-incomplete');
-       }
-       if (hasFinishedRound(user, round)) {
-               classNames.push('has-finished');
-       } else if (isRunner(user, tournament)) {
-               classNames.push('has-not-finished');
-       }
-       return classNames.join(' ');
-};
-
-const Item = ({
-       round,
-       tournament,
-}) => {
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       return <li className={getClassName(round, tournament, user)}>
-               {round.title ?
-                       <h3>{round.title}</h3>
-               : null}
-               <div className="d-flex">
-                       <div className="info">
-                               <p className="date">
-                                       {tournament.show_numbers && round.number ? `#${round.number} ` : ''}
-                                       {t('rounds.date', { date: new Date(round.created_at) })}
-                               </p>
-                               <p className="seed">
-                                       {round.code && round.code.length ?
-                                               <>
-                                                       <SeedCode code={round.code} game={round.game || 'alttpr'} />
-                                                       <br />
-                                               </>
-                                       : null}
-                                       <SeedButton
-                                               round={round}
-                                               tournament={tournament}
-                                       />
-                                       {' '}
-                                       <SeedRolledBy round={round} />
-                               </p>
-                               {mayReportResult(user, tournament) ?
-                                       <p className="report">
-                                               <ReportButton
-                                                       round={round}
-                                                       tournament={tournament}
-                                                       user={user}
-                                               />
-                                       </p>
-                               : null}
-                               <div className="bottom-half">
-                                       {tournament.type === 'open-async' && round.results && round.results.length ?
-                                               <p>{t('rounds.numberOfResults', { count: round.results.length })}</p>
-                                       : null}
-                                       <div className="button-bar">
-                                               <LockButton round={round} tournament={tournament} />
-                                               {mayEditRound(user, tournament, round) ?
-                                                       <EditButton round={round} tournament={tournament} />
-                                               : null}
-                                               {mayViewProtocol(user, tournament, round) ?
-                                                       <RoundProtocol roundId={round.id} tournamentId={tournament.id} />
-                                               : null}
-                                               {mayDeleteRound(user, tournament, round) ?
-                                                       <DeleteButton round={round} tournament={tournament} />
-                                               : null}
-                                       </div>
-                               </div>
-                       </div>
-                       <List round={round} tournament={tournament} />
-               </div>
-       </li>;
-};
-
-Item.propTypes = {
-       round: PropTypes.shape({
-               code: PropTypes.arrayOf(PropTypes.string),
-               created_at: PropTypes.string,
-               game: PropTypes.string,
-               id: PropTypes.number,
-               locked: PropTypes.bool,
-               number: PropTypes.number,
-               results: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               seed: PropTypes.string,
-               title: PropTypes.string,
-       }),
-       tournament: PropTypes.shape({
-               participants: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               id: PropTypes.number,
-               show_numbers: PropTypes.bool,
-               type: PropTypes.string,
-       }),
-};
-
-export default Item;
diff --git a/resources/js/components/rounds/Item.jsx b/resources/js/components/rounds/Item.jsx
new file mode 100644 (file)
index 0000000..9cd095f
--- /dev/null
@@ -0,0 +1,130 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import DeleteButton from './DeleteButton';
+import EditButton from './EditButton';
+import LockButton from './LockButton';
+import SeedButton from './SeedButton';
+import SeedCode from './SeedCode';
+import SeedRolledBy from './SeedRolledBy';
+import RoundProtocol from '../protocol/RoundProtocol';
+import List from '../results/List';
+import ReportButton from '../results/ReportButton';
+import {
+       mayDeleteRound,
+       mayEditRound,
+       mayReportResult,
+       mayViewProtocol,
+       isRunner,
+} from '../../helpers/permissions';
+import { isComplete } from '../../helpers/Round';
+import { hasFinishedRound } from '../../helpers/User';
+import { useUser } from '../../hooks/user';
+
+const getClassName = (round, tournament, user) => {
+       const classNames = ['round'];
+       if (round.locked) {
+               classNames.push('is-locked');
+       } else {
+               classNames.push('is-unlocked');
+       }
+       if (isComplete(tournament, round)) {
+               classNames.push('is-complete');
+       } else {
+               classNames.push('is-incomplete');
+       }
+       if (hasFinishedRound(user, round)) {
+               classNames.push('has-finished');
+       } else if (isRunner(user, tournament)) {
+               classNames.push('has-not-finished');
+       }
+       return classNames.join(' ');
+};
+
+const Item = ({
+       round,
+       tournament,
+}) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       return <li className={getClassName(round, tournament, user)}>
+               {round.title ?
+                       <h3>{round.title}</h3>
+               : null}
+               <div className="d-flex">
+                       <div className="info">
+                               <p className="date">
+                                       {tournament.show_numbers && round.number ? `#${round.number} ` : ''}
+                                       {t('rounds.date', { date: new Date(round.created_at) })}
+                               </p>
+                               <p className="seed">
+                                       {round.code && round.code.length ?
+                                               <>
+                                                       <SeedCode code={round.code} game={round.game || 'alttpr'} />
+                                                       <br />
+                                               </>
+                                       : null}
+                                       <SeedButton
+                                               round={round}
+                                               tournament={tournament}
+                                       />
+                                       {' '}
+                                       <SeedRolledBy round={round} />
+                               </p>
+                               {mayReportResult(user, tournament) ?
+                                       <p className="report">
+                                               <ReportButton
+                                                       round={round}
+                                                       tournament={tournament}
+                                                       user={user}
+                                               />
+                                       </p>
+                               : null}
+                               <div className="bottom-half">
+                                       {tournament.type === 'open-async' && round.results && round.results.length ?
+                                               <p>{t('rounds.numberOfResults', { count: round.results.length })}</p>
+                                       : null}
+                                       <div className="button-bar">
+                                               <LockButton round={round} tournament={tournament} />
+                                               {mayEditRound(user, tournament, round) ?
+                                                       <EditButton round={round} tournament={tournament} />
+                                               : null}
+                                               {mayViewProtocol(user, tournament, round) ?
+                                                       <RoundProtocol roundId={round.id} tournamentId={tournament.id} />
+                                               : null}
+                                               {mayDeleteRound(user, tournament, round) ?
+                                                       <DeleteButton round={round} tournament={tournament} />
+                                               : null}
+                                       </div>
+                               </div>
+                       </div>
+                       <List round={round} tournament={tournament} />
+               </div>
+       </li>;
+};
+
+Item.propTypes = {
+       round: PropTypes.shape({
+               code: PropTypes.arrayOf(PropTypes.string),
+               created_at: PropTypes.string,
+               game: PropTypes.string,
+               id: PropTypes.number,
+               locked: PropTypes.bool,
+               number: PropTypes.number,
+               results: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               seed: PropTypes.string,
+               title: PropTypes.string,
+       }),
+       tournament: PropTypes.shape({
+               participants: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               id: PropTypes.number,
+               show_numbers: PropTypes.bool,
+               type: PropTypes.string,
+       }),
+};
+
+export default Item;
diff --git a/resources/js/components/rounds/List.js b/resources/js/components/rounds/List.js
deleted file mode 100644 (file)
index 5aa2a4c..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import Item from './Item';
-import LoadMore from './LoadMore';
-import i18n from '../../i18n';
-
-const List = ({
-       loadMore,
-       rounds,
-       tournament,
-}) => rounds && rounds.length ? <>
-       <ol className="rounds">
-               {rounds.map(round =>
-                       <Item
-                               key={round.id}
-                               round={round}
-                               tournament={tournament}
-                       />
-               )}
-       </ol>
-       {loadMore ?
-               <LoadMore loadMore={loadMore} />
-       : null}
-</> :
-       <Alert variant="info">
-               {i18n.t('rounds.empty')}
-       </Alert>
-;
-
-List.propTypes = {
-       loadMore: PropTypes.func,
-       rounds: PropTypes.arrayOf(PropTypes.shape({
-               id: PropTypes.number,
-       })),
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(List);
diff --git a/resources/js/components/rounds/List.jsx b/resources/js/components/rounds/List.jsx
new file mode 100644 (file)
index 0000000..5aa2a4c
--- /dev/null
@@ -0,0 +1,42 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Item from './Item';
+import LoadMore from './LoadMore';
+import i18n from '../../i18n';
+
+const List = ({
+       loadMore,
+       rounds,
+       tournament,
+}) => rounds && rounds.length ? <>
+       <ol className="rounds">
+               {rounds.map(round =>
+                       <Item
+                               key={round.id}
+                               round={round}
+                               tournament={tournament}
+                       />
+               )}
+       </ol>
+       {loadMore ?
+               <LoadMore loadMore={loadMore} />
+       : null}
+</> :
+       <Alert variant="info">
+               {i18n.t('rounds.empty')}
+       </Alert>
+;
+
+List.propTypes = {
+       loadMore: PropTypes.func,
+       rounds: PropTypes.arrayOf(PropTypes.shape({
+               id: PropTypes.number,
+       })),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(List);
diff --git a/resources/js/components/rounds/LoadMore.js b/resources/js/components/rounds/LoadMore.js
deleted file mode 100644 (file)
index d0ea365..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-const LoadMore = ({ loadMore }) => {
-       const [loading, setLoading] = React.useState(false);
-
-       const { t } = useTranslation();
-
-       const handleLoadMore = React.useCallback(async () => {
-               setLoading(true);
-               try {
-                       await loadMore();
-               } finally {
-                       setLoading(false);
-               }
-       }, [loadMore]);
-
-       return <div className="d-grid mt-2">
-               <Button disabled={loading} onClick={handleLoadMore} variant="outline-secondary">
-               {t('rounds.loadMore')}
-               </Button>
-       </div>;
-};
-
-LoadMore.propTypes = {
-       loadMore: PropTypes.func,
-};
-
-export default LoadMore;
diff --git a/resources/js/components/rounds/LoadMore.jsx b/resources/js/components/rounds/LoadMore.jsx
new file mode 100644 (file)
index 0000000..d0ea365
--- /dev/null
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const LoadMore = ({ loadMore }) => {
+       const [loading, setLoading] = React.useState(false);
+
+       const { t } = useTranslation();
+
+       const handleLoadMore = React.useCallback(async () => {
+               setLoading(true);
+               try {
+                       await loadMore();
+               } finally {
+                       setLoading(false);
+               }
+       }, [loadMore]);
+
+       return <div className="d-grid mt-2">
+               <Button disabled={loading} onClick={handleLoadMore} variant="outline-secondary">
+               {t('rounds.loadMore')}
+               </Button>
+       </div>;
+};
+
+LoadMore.propTypes = {
+       loadMore: PropTypes.func,
+};
+
+export default LoadMore;
diff --git a/resources/js/components/rounds/LockButton.js b/resources/js/components/rounds/LockButton.js
deleted file mode 100644 (file)
index c29b3d3..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import LockDialog from './LockDialog';
-import Icon from '../common/Icon';
-import { mayLockRound } from '../../helpers/permissions';
-import { useUser } from '../../hooks/user';
-
-const LockButton = ({
-       round,
-       tournament,
-}) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       if (!mayLockRound(user, tournament, round)) {
-               if (round.locked) {
-                       return <Icon.LOCKED title={t('rounds.locked')} />;
-               } else {
-                       return <Icon.UNLOCKED title={t('rounds.unlocked')} />;
-               }
-       }
-
-       return <>
-               <LockDialog
-                       onHide={() => setShowDialog(false)}
-                       round={round}
-                       show={showDialog}
-                       tournament={tournament}
-               />
-               <Button
-                       onClick={() => setShowDialog(true)}
-                       size="sm"
-                       title={t(round.locked ? 'rounds.locked' : 'rounds.unlocked')}
-                       variant="outline-secondary"
-               >
-                       {round.locked ?
-                               <Icon.LOCKED title="" />
-                       :
-                               <Icon.UNLOCKED title="" />
-                       }
-               </Button>
-       </>;
-};
-
-LockButton.propTypes = {
-       round: PropTypes.shape({
-               locked: PropTypes.bool,
-       }),
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default LockButton;
diff --git a/resources/js/components/rounds/LockButton.jsx b/resources/js/components/rounds/LockButton.jsx
new file mode 100644 (file)
index 0000000..c29b3d3
--- /dev/null
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import LockDialog from './LockDialog';
+import Icon from '../common/Icon';
+import { mayLockRound } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const LockButton = ({
+       round,
+       tournament,
+}) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       if (!mayLockRound(user, tournament, round)) {
+               if (round.locked) {
+                       return <Icon.LOCKED title={t('rounds.locked')} />;
+               } else {
+                       return <Icon.UNLOCKED title={t('rounds.unlocked')} />;
+               }
+       }
+
+       return <>
+               <LockDialog
+                       onHide={() => setShowDialog(false)}
+                       round={round}
+                       show={showDialog}
+                       tournament={tournament}
+               />
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       size="sm"
+                       title={t(round.locked ? 'rounds.locked' : 'rounds.unlocked')}
+                       variant="outline-secondary"
+               >
+                       {round.locked ?
+                               <Icon.LOCKED title="" />
+                       :
+                               <Icon.UNLOCKED title="" />
+                       }
+               </Button>
+       </>;
+};
+
+LockButton.propTypes = {
+       round: PropTypes.shape({
+               locked: PropTypes.bool,
+       }),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default LockButton;
diff --git a/resources/js/components/rounds/LockDialog.js b/resources/js/components/rounds/LockDialog.js
deleted file mode 100644 (file)
index 6174dbf..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button, Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import { isComplete } from '../../helpers/Round';
-import i18n from '../../i18n';
-
-const LockDialog = ({
-       onHide,
-       round,
-       show,
-       tournament,
-}) =>
-<Modal className="lock-dialog" onHide={onHide} show={show}>
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')}
-               </Modal.Title>
-       </Modal.Header>
-       <Modal.Body>
-               <p>{i18n.t(round.locked
-                       ? 'rounds.unlockDescription'
-                       : 'rounds.lockDescription')}
-               </p>
-       {!round.locked && tournament.type === 'signup-async' && !isComplete(tournament, round) ?
-               <Alert variant="warning">
-                       {i18n.t('rounds.lockIncompleteWarning')}
-               </Alert>
-       : null}
-       </Modal.Body>
-       <Modal.Footer>
-               {onHide ?
-                       <Button onClick={onHide} variant="secondary">
-                               {i18n.t('button.cancel')}
-                       </Button>
-               : null}
-               <Button
-                       onClick={async () => {
-                               if (round.locked) {
-                                       try {
-                                               await axios.post(`/api/rounds/${round.id}/unlock`);
-                                               toastr.success(i18n.t('rounds.unlockSuccess'));
-                                               onHide();
-                                       } catch (e) {
-                                               toastr.error(i18n.t('rounds.unlockError'));
-                                       }
-                               } else {
-                                       try {
-                                               await axios.post(`/api/rounds/${round.id}/lock`);
-                                               toastr.success(i18n.t('rounds.lockSuccess'));
-                                               onHide();
-                                       } catch (e) {
-                                               toastr.error(i18n.t('rounds.lockError'));
-                                       }
-                               }
-                       }}
-                       variant="primary"
-               >
-                       {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')}
-               </Button>
-       </Modal.Footer>
-</Modal>;
-
-LockDialog.propTypes = {
-       onHide: PropTypes.func,
-       round: PropTypes.shape({
-               id: PropTypes.number,
-               locked: PropTypes.bool,
-       }),
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-               type: PropTypes.string,
-       }),
-};
-
-export default withTranslation()(LockDialog);
diff --git a/resources/js/components/rounds/LockDialog.jsx b/resources/js/components/rounds/LockDialog.jsx
new file mode 100644 (file)
index 0000000..6174dbf
--- /dev/null
@@ -0,0 +1,79 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import { isComplete } from '../../helpers/Round';
+import i18n from '../../i18n';
+
+const LockDialog = ({
+       onHide,
+       round,
+       show,
+       tournament,
+}) =>
+<Modal className="lock-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')}
+               </Modal.Title>
+       </Modal.Header>
+       <Modal.Body>
+               <p>{i18n.t(round.locked
+                       ? 'rounds.unlockDescription'
+                       : 'rounds.lockDescription')}
+               </p>
+       {!round.locked && tournament.type === 'signup-async' && !isComplete(tournament, round) ?
+               <Alert variant="warning">
+                       {i18n.t('rounds.lockIncompleteWarning')}
+               </Alert>
+       : null}
+       </Modal.Body>
+       <Modal.Footer>
+               {onHide ?
+                       <Button onClick={onHide} variant="secondary">
+                               {i18n.t('button.cancel')}
+                       </Button>
+               : null}
+               <Button
+                       onClick={async () => {
+                               if (round.locked) {
+                                       try {
+                                               await axios.post(`/api/rounds/${round.id}/unlock`);
+                                               toastr.success(i18n.t('rounds.unlockSuccess'));
+                                               onHide();
+                                       } catch (e) {
+                                               toastr.error(i18n.t('rounds.unlockError'));
+                                       }
+                               } else {
+                                       try {
+                                               await axios.post(`/api/rounds/${round.id}/lock`);
+                                               toastr.success(i18n.t('rounds.lockSuccess'));
+                                               onHide();
+                                       } catch (e) {
+                                               toastr.error(i18n.t('rounds.lockError'));
+                                       }
+                               }
+                       }}
+                       variant="primary"
+               >
+                       {i18n.t(round.locked ? 'rounds.unlock' : 'rounds.lock')}
+               </Button>
+       </Modal.Footer>
+</Modal>;
+
+LockDialog.propTypes = {
+       onHide: PropTypes.func,
+       round: PropTypes.shape({
+               id: PropTypes.number,
+               locked: PropTypes.bool,
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+               type: PropTypes.string,
+       }),
+};
+
+export default withTranslation()(LockDialog);
diff --git a/resources/js/components/rounds/SeedButton.js b/resources/js/components/rounds/SeedButton.js
deleted file mode 100644 (file)
index 59bc3ba..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import SeedDialog from './SeedDialog';
-import { maySetSeed } from '../../helpers/permissions';
-import { useUser } from '../../hooks/user';
-
-const SeedButton = ({ round, tournament }) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       if (round.seed) {
-               return <>
-                       <Button href={round.seed} target="_blank" variant="primary">
-                               {t('rounds.seed')}
-                       </Button>
-                       {round.spoiler ?
-                               <Button
-                                       className="ms-2"
-                                       href={round.spoiler}
-                                       target="_blank"
-                                       variant="outline-primary"
-                               >
-                                       {t('rounds.spoiler')}
-                               </Button>
-                       : null}
-               </>;
-       }
-       if (maySetSeed(user, tournament, round)) {
-               return <>
-                       <SeedDialog
-                               onHide={() => setShowDialog(false)}
-                               round={round}
-                               show={showDialog}
-                       />
-                       <Button onClick={() => setShowDialog(true)} variant="outline-primary">
-                               {t('rounds.setSeed')}
-                       </Button>
-               </>;
-       }
-       return t('rounds.noSeed');
-};
-
-SeedButton.propTypes = {
-       round: PropTypes.shape({
-               seed: PropTypes.string,
-               spoiler: PropTypes.string,
-       }),
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default SeedButton;
diff --git a/resources/js/components/rounds/SeedButton.jsx b/resources/js/components/rounds/SeedButton.jsx
new file mode 100644 (file)
index 0000000..59bc3ba
--- /dev/null
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import SeedDialog from './SeedDialog';
+import { maySetSeed } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const SeedButton = ({ round, tournament }) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       if (round.seed) {
+               return <>
+                       <Button href={round.seed} target="_blank" variant="primary">
+                               {t('rounds.seed')}
+                       </Button>
+                       {round.spoiler ?
+                               <Button
+                                       className="ms-2"
+                                       href={round.spoiler}
+                                       target="_blank"
+                                       variant="outline-primary"
+                               >
+                                       {t('rounds.spoiler')}
+                               </Button>
+                       : null}
+               </>;
+       }
+       if (maySetSeed(user, tournament, round)) {
+               return <>
+                       <SeedDialog
+                               onHide={() => setShowDialog(false)}
+                               round={round}
+                               show={showDialog}
+                       />
+                       <Button onClick={() => setShowDialog(true)} variant="outline-primary">
+                               {t('rounds.setSeed')}
+                       </Button>
+               </>;
+       }
+       return t('rounds.noSeed');
+};
+
+SeedButton.propTypes = {
+       round: PropTypes.shape({
+               seed: PropTypes.string,
+               spoiler: PropTypes.string,
+       }),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default SeedButton;
diff --git a/resources/js/components/rounds/SeedCode.js b/resources/js/components/rounds/SeedCode.js
deleted file mode 100644 (file)
index 2f74279..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import ZeldaIcon from '../common/ZeldaIcon';
-
-const SeedCode = ({ code, game }) =>
-<span className={`seed-code game-${game}`}>
-       {code.map(game === 'smr'
-               ? (symbol, index) => <span key={`${symbol}.${index}`}>{symbol}</span>
-               : (symbol, index) => <ZeldaIcon key={`${symbol}.${index}`} name={symbol} />
-       )}
-</span>;
-
-SeedCode.propTypes = {
-       code: PropTypes.arrayOf(PropTypes.string),
-       game: PropTypes.string,
-};
-
-export default SeedCode;
diff --git a/resources/js/components/rounds/SeedCode.jsx b/resources/js/components/rounds/SeedCode.jsx
new file mode 100644 (file)
index 0000000..2f74279
--- /dev/null
@@ -0,0 +1,19 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import ZeldaIcon from '../common/ZeldaIcon';
+
+const SeedCode = ({ code, game }) =>
+<span className={`seed-code game-${game}`}>
+       {code.map(game === 'smr'
+               ? (symbol, index) => <span key={`${symbol}.${index}`}>{symbol}</span>
+               : (symbol, index) => <ZeldaIcon key={`${symbol}.${index}`} name={symbol} />
+       )}
+</span>;
+
+SeedCode.propTypes = {
+       code: PropTypes.arrayOf(PropTypes.string),
+       game: PropTypes.string,
+};
+
+export default SeedCode;
diff --git a/resources/js/components/rounds/SeedCodeInput.js b/resources/js/components/rounds/SeedCodeInput.js
deleted file mode 100644 (file)
index 958c927..0000000
+++ /dev/null
@@ -1,153 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Form } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import i18n from '../../i18n';
-
-const ALTTPR_CODES = [
-       'big-key',
-       'blue-boomerang',
-       'bomb',
-       'bombos',
-       'book',
-       'boots',
-       'bottle',
-       'bow',
-       'bugnet',
-       'cape',
-       'compass',
-       'ether',
-       'flippers',
-       'flute',
-       'glove',
-       'green-mail',
-       'green-pendant',
-       'green-potion',
-       'hammer',
-       'heart-container',
-       'hookshot',
-       'ice-rod',
-       'lamp',
-       'map',
-       'mirror',
-       'mirror-shield',
-       'moonpearl',
-       'mushroom',
-       'powder',
-       'quake',
-       'shovel',
-       'somaria',
-];
-
-const SMR_CODES = [
-       'ALCOON',
-       'ATOMIC',
-       'BEETOM',
-       'BOYON',
-       'BULL',
-       'CHOOT',
-       'COVERN',
-       'EVIR',
-       'FUNE',
-       'GAMET',
-       'GEEMER',
-       'GERUTA',
-       'HOLTZ',
-       'KAGO',
-       'NAMIHE',
-       'OUM',
-       'OWTCH',
-       'POWAMP',
-       'PUROMI',
-       'PUYO',
-       'RINKA',
-       'RIPPER',
-       'SCISER',
-       'SKREE',
-       'SOVA',
-       'TATORI',
-       'VIOLA',
-       'WAVER',
-       'YARD',
-       'ZEBBO',
-       'ZEELA',
-       'ZOA',
-];
-
-const SeedCodeInput = ({
-       className = '',
-       game = '',
-       name = '',
-       onBlur = null,
-       onChange = null,
-       value = [],
-}) => {
-       if (game === 'alttpr') {
-               const code_trans = ALTTPR_CODES
-                       .map(code => ({ code, label: i18n.t(`icon.zelda.${code}`)}))
-                       .sort((a, b) => a.label.localeCompare(b.label));
-               return <div
-                       className={`${className} seed-code-input game-alttpr`}
-               >
-                       {[0, 1, 2, 3, 4].map(num =>
-                               <Form.Select
-                                       key={num}
-                                       onBlur={onBlur}
-                                       onChange={onChange}
-                                       name={`${name}[${num}]`}
-                                       value={(value && value[num]) || ''}
-                               >
-                                       <option value=""></option>
-                                       {code_trans.map(({ code, label }) =>
-                                               <option key={code} value={code}>{label}</option>
-                                       )}
-                               </Form.Select>
-                       )}
-               </div>;
-       }
-       if (game === 'smr') {
-               return <div
-                       className={`${className} seed-code-input game-smr`}
-               >
-                       {[0, 1, 2, 3].map(num =>
-                               <Form.Select
-                                       key={num}
-                                       onBlur={onBlur}
-                                       onChange={onChange}
-                                       name={`${name}[${num}]`}
-                                       value={(value && value[num]) || ''}
-                               >
-                                       <option value=""></option>
-                                       {SMR_CODES.sort((a, b) => a.localeCompare(b)).map(code =>
-                                               <option key={code} value={code}>{code}</option>
-                                       )}
-                               </Form.Select>
-                       )}
-               </div>;
-       }
-       return <div
-               className={`${className} seed-code-input`}
-       >
-               {[0, 1, 2, 3, 4].map(num =>
-                       <Form.Control
-                               key={num}
-                               onBlur={onBlur}
-                               onChange={onChange}
-                               name={`${name}[${num}]`}
-                               value={(value && value[num]) || ''}
-                       />
-               )}
-       </div>;
-};
-
-SeedCodeInput.propTypes = {
-       className: PropTypes.string,
-       game: PropTypes.string,
-       name: PropTypes.string,
-       onBlur: PropTypes.func,
-       onChange: PropTypes.func,
-       value: PropTypes.arrayOf(PropTypes.string),
-};
-
-export default withTranslation()(SeedCodeInput);
diff --git a/resources/js/components/rounds/SeedCodeInput.jsx b/resources/js/components/rounds/SeedCodeInput.jsx
new file mode 100644 (file)
index 0000000..958c927
--- /dev/null
@@ -0,0 +1,153 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Form } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import i18n from '../../i18n';
+
+const ALTTPR_CODES = [
+       'big-key',
+       'blue-boomerang',
+       'bomb',
+       'bombos',
+       'book',
+       'boots',
+       'bottle',
+       'bow',
+       'bugnet',
+       'cape',
+       'compass',
+       'ether',
+       'flippers',
+       'flute',
+       'glove',
+       'green-mail',
+       'green-pendant',
+       'green-potion',
+       'hammer',
+       'heart-container',
+       'hookshot',
+       'ice-rod',
+       'lamp',
+       'map',
+       'mirror',
+       'mirror-shield',
+       'moonpearl',
+       'mushroom',
+       'powder',
+       'quake',
+       'shovel',
+       'somaria',
+];
+
+const SMR_CODES = [
+       'ALCOON',
+       'ATOMIC',
+       'BEETOM',
+       'BOYON',
+       'BULL',
+       'CHOOT',
+       'COVERN',
+       'EVIR',
+       'FUNE',
+       'GAMET',
+       'GEEMER',
+       'GERUTA',
+       'HOLTZ',
+       'KAGO',
+       'NAMIHE',
+       'OUM',
+       'OWTCH',
+       'POWAMP',
+       'PUROMI',
+       'PUYO',
+       'RINKA',
+       'RIPPER',
+       'SCISER',
+       'SKREE',
+       'SOVA',
+       'TATORI',
+       'VIOLA',
+       'WAVER',
+       'YARD',
+       'ZEBBO',
+       'ZEELA',
+       'ZOA',
+];
+
+const SeedCodeInput = ({
+       className = '',
+       game = '',
+       name = '',
+       onBlur = null,
+       onChange = null,
+       value = [],
+}) => {
+       if (game === 'alttpr') {
+               const code_trans = ALTTPR_CODES
+                       .map(code => ({ code, label: i18n.t(`icon.zelda.${code}`)}))
+                       .sort((a, b) => a.label.localeCompare(b.label));
+               return <div
+                       className={`${className} seed-code-input game-alttpr`}
+               >
+                       {[0, 1, 2, 3, 4].map(num =>
+                               <Form.Select
+                                       key={num}
+                                       onBlur={onBlur}
+                                       onChange={onChange}
+                                       name={`${name}[${num}]`}
+                                       value={(value && value[num]) || ''}
+                               >
+                                       <option value=""></option>
+                                       {code_trans.map(({ code, label }) =>
+                                               <option key={code} value={code}>{label}</option>
+                                       )}
+                               </Form.Select>
+                       )}
+               </div>;
+       }
+       if (game === 'smr') {
+               return <div
+                       className={`${className} seed-code-input game-smr`}
+               >
+                       {[0, 1, 2, 3].map(num =>
+                               <Form.Select
+                                       key={num}
+                                       onBlur={onBlur}
+                                       onChange={onChange}
+                                       name={`${name}[${num}]`}
+                                       value={(value && value[num]) || ''}
+                               >
+                                       <option value=""></option>
+                                       {SMR_CODES.sort((a, b) => a.localeCompare(b)).map(code =>
+                                               <option key={code} value={code}>{code}</option>
+                                       )}
+                               </Form.Select>
+                       )}
+               </div>;
+       }
+       return <div
+               className={`${className} seed-code-input`}
+       >
+               {[0, 1, 2, 3, 4].map(num =>
+                       <Form.Control
+                               key={num}
+                               onBlur={onBlur}
+                               onChange={onChange}
+                               name={`${name}[${num}]`}
+                               value={(value && value[num]) || ''}
+                       />
+               )}
+       </div>;
+};
+
+SeedCodeInput.propTypes = {
+       className: PropTypes.string,
+       game: PropTypes.string,
+       name: PropTypes.string,
+       onBlur: PropTypes.func,
+       onChange: PropTypes.func,
+       value: PropTypes.arrayOf(PropTypes.string),
+};
+
+export default withTranslation()(SeedCodeInput);
diff --git a/resources/js/components/rounds/SeedDialog.js b/resources/js/components/rounds/SeedDialog.js
deleted file mode 100644 (file)
index 2ee3658..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import SeedForm from './SeedForm';
-import i18n from '../../i18n';
-
-const SeedDialog = ({
-       onHide,
-       round,
-       show,
-}) =>
-<Modal className="seed-dialog" onHide={onHide} show={show}>
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t('rounds.setSeed')}
-               </Modal.Title>
-       </Modal.Header>
-       <SeedForm
-               onCancel={onHide}
-               round={round}
-       />
-</Modal>;
-
-SeedDialog.propTypes = {
-       onHide: PropTypes.func,
-       round: PropTypes.shape({
-       }),
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(SeedDialog);
diff --git a/resources/js/components/rounds/SeedDialog.jsx b/resources/js/components/rounds/SeedDialog.jsx
new file mode 100644 (file)
index 0000000..2ee3658
--- /dev/null
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import SeedForm from './SeedForm';
+import i18n from '../../i18n';
+
+const SeedDialog = ({
+       onHide,
+       round,
+       show,
+}) =>
+<Modal className="seed-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('rounds.setSeed')}
+               </Modal.Title>
+       </Modal.Header>
+       <SeedForm
+               onCancel={onHide}
+               round={round}
+       />
+</Modal>;
+
+SeedDialog.propTypes = {
+       onHide: PropTypes.func,
+       round: PropTypes.shape({
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(SeedDialog);
diff --git a/resources/js/components/rounds/SeedForm.js b/resources/js/components/rounds/SeedForm.js
deleted file mode 100644 (file)
index 3cff560..0000000
+++ /dev/null
@@ -1,101 +0,0 @@
-import axios from 'axios';
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import i18n from '../../i18n';
-import yup from '../../schema/yup';
-
-const SeedForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       touched,
-       values,
-}) =>
-<Form noValidate onSubmit={handleSubmit}>
-       <Modal.Body>
-               <Row>
-                       <Form.Group as={Col} controlId="round.seed">
-                               <Form.Label>{i18n.t('rounds.seed')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.seed && errors.seed)}
-                                       name="seed"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       placeholder="https://alttprpatch.synack.live/patcher.html?patch=https://sahasrahbot.s3.amazonaws.com/patch/DR_XXXXXXXXXXX.bps"
-                                       type="text"
-                                       value={values.seed || ''}
-                               />
-                               {touched.seed && errors.seed ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {i18n.t(errors.seed)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-       </Modal.Body>
-       <Modal.Footer>
-               {onCancel ?
-                       <Button onClick={onCancel} variant="secondary">
-                               {i18n.t('button.cancel')}
-                       </Button>
-               : null}
-               <Button type="submit" variant="primary">
-                       {i18n.t('button.save')}
-               </Button>
-       </Modal.Footer>
-</Form>;
-
-SeedForm.propTypes = {
-       errors: PropTypes.shape({
-               seed: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               seed: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               seed: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'SeedForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { round_id, seed } = values;
-               const { setErrors } = actions;
-               const { onCancel } = actions.props;
-               try {
-                       await axios.post(`/api/rounds/${round_id}/setSeed`, {
-                               seed,
-                       });
-                       toastr.success(i18n.t('rounds.setSeedSuccess'));
-                       if (onCancel) {
-                               onCancel();
-                       }
-               } catch (e) {
-                       toastr.error(i18n.t('rounds.setSeedError'));
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ round }) => ({
-               round_id: round.id,
-               seed: round.seed || '',
-       }),
-       validationSchema: yup.object().shape({
-               seed: yup.string().required().url(),
-       }),
-})(withTranslation()(SeedForm));
diff --git a/resources/js/components/rounds/SeedForm.jsx b/resources/js/components/rounds/SeedForm.jsx
new file mode 100644 (file)
index 0000000..3cff560
--- /dev/null
@@ -0,0 +1,101 @@
+import axios from 'axios';
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import i18n from '../../i18n';
+import yup from '../../schema/yup';
+
+const SeedForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       values,
+}) =>
+<Form noValidate onSubmit={handleSubmit}>
+       <Modal.Body>
+               <Row>
+                       <Form.Group as={Col} controlId="round.seed">
+                               <Form.Label>{i18n.t('rounds.seed')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.seed && errors.seed)}
+                                       name="seed"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       placeholder="https://alttprpatch.synack.live/patcher.html?patch=https://sahasrahbot.s3.amazonaws.com/patch/DR_XXXXXXXXXXX.bps"
+                                       type="text"
+                                       value={values.seed || ''}
+                               />
+                               {touched.seed && errors.seed ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.seed)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+       </Modal.Body>
+       <Modal.Footer>
+               {onCancel ?
+                       <Button onClick={onCancel} variant="secondary">
+                               {i18n.t('button.cancel')}
+                       </Button>
+               : null}
+               <Button type="submit" variant="primary">
+                       {i18n.t('button.save')}
+               </Button>
+       </Modal.Footer>
+</Form>;
+
+SeedForm.propTypes = {
+       errors: PropTypes.shape({
+               seed: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               seed: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               seed: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'SeedForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { round_id, seed } = values;
+               const { setErrors } = actions;
+               const { onCancel } = actions.props;
+               try {
+                       await axios.post(`/api/rounds/${round_id}/setSeed`, {
+                               seed,
+                       });
+                       toastr.success(i18n.t('rounds.setSeedSuccess'));
+                       if (onCancel) {
+                               onCancel();
+                       }
+               } catch (e) {
+                       toastr.error(i18n.t('rounds.setSeedError'));
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ round }) => ({
+               round_id: round.id,
+               seed: round.seed || '',
+       }),
+       validationSchema: yup.object().shape({
+               seed: yup.string().required().url(),
+       }),
+})(withTranslation()(SeedForm));
diff --git a/resources/js/components/rounds/SeedRolledBy.js b/resources/js/components/rounds/SeedRolledBy.js
deleted file mode 100644 (file)
index cf73401..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import { getAvatarUrl, getUserName } from '../../helpers/User';
-import i18n from '../../i18n';
-
-const SeedRolledBy = ({ round }) => round.rolled_by_user ?
-       <span
-               className="rolled-by"
-               title={i18n.t('rounds.rolledBy', { name: getUserName(round.rolled_by_user) })}
-       >
-               <img alt={getUserName(round.rolled_by_user)} src={getAvatarUrl(round.rolled_by_user)} />
-       </span>
-: null;
-
-SeedRolledBy.propTypes = {
-       round: PropTypes.shape({
-               rolled_by_user: PropTypes.shape({
-               }),
-       }),
-};
-
-export default SeedRolledBy;
diff --git a/resources/js/components/rounds/SeedRolledBy.jsx b/resources/js/components/rounds/SeedRolledBy.jsx
new file mode 100644 (file)
index 0000000..cf73401
--- /dev/null
@@ -0,0 +1,23 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import { getAvatarUrl, getUserName } from '../../helpers/User';
+import i18n from '../../i18n';
+
+const SeedRolledBy = ({ round }) => round.rolled_by_user ?
+       <span
+               className="rolled-by"
+               title={i18n.t('rounds.rolledBy', { name: getUserName(round.rolled_by_user) })}
+       >
+               <img alt={getUserName(round.rolled_by_user)} src={getAvatarUrl(round.rolled_by_user)} />
+       </span>
+: null;
+
+SeedRolledBy.propTypes = {
+       round: PropTypes.shape({
+               rolled_by_user: PropTypes.shape({
+               }),
+       }),
+};
+
+export default SeedRolledBy;
diff --git a/resources/js/components/snes/SettingsDialog.js b/resources/js/components/snes/SettingsDialog.js
deleted file mode 100644 (file)
index 0f47d8f..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import SettingsForm from './SettingsForm';
-
-const SettingsDialog = ({
-       deviceList,
-       onHide,
-       onSubmit,
-       settings,
-       show,
-}) => {
-       const { t } = useTranslation();
-
-       return <Modal className="snes-settings-dialog" onHide={onHide} show={show}>
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('snes.settings')}
-                       </Modal.Title>
-               </Modal.Header>
-               <SettingsForm
-                       deviceList={deviceList}
-                       onCancel={onHide}
-                       onSubmit={onSubmit}
-                       settings={settings}
-               />
-       </Modal>;
-};
-
-SettingsDialog.propTypes = {
-       deviceList: PropTypes.arrayOf(PropTypes.string),
-       onHide: PropTypes.func,
-       onSubmit: PropTypes.func,
-       settings: PropTypes.shape({
-       }),
-       show: PropTypes.bool,
-};
-
-export default SettingsDialog;
diff --git a/resources/js/components/snes/SettingsDialog.jsx b/resources/js/components/snes/SettingsDialog.jsx
new file mode 100644 (file)
index 0000000..0f47d8f
--- /dev/null
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import SettingsForm from './SettingsForm';
+
+const SettingsDialog = ({
+       deviceList,
+       onHide,
+       onSubmit,
+       settings,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal className="snes-settings-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('snes.settings')}
+                       </Modal.Title>
+               </Modal.Header>
+               <SettingsForm
+                       deviceList={deviceList}
+                       onCancel={onHide}
+                       onSubmit={onSubmit}
+                       settings={settings}
+               />
+       </Modal>;
+};
+
+SettingsDialog.propTypes = {
+       deviceList: PropTypes.arrayOf(PropTypes.string),
+       onHide: PropTypes.func,
+       onSubmit: PropTypes.func,
+       settings: PropTypes.shape({
+       }),
+       show: PropTypes.bool,
+};
+
+export default SettingsDialog;
diff --git a/resources/js/components/snes/SettingsForm.js b/resources/js/components/snes/SettingsForm.js
deleted file mode 100644 (file)
index 7e05108..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import yup from '../../schema/yup';
-
-const SettingsForm = ({
-       deviceList,
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       settings,
-       touched,
-       values,
-}) => {
-       const { t } = useTranslation();
-
-       return <Form noValidate onSubmit={handleSubmit}>
-               <Modal.Body>
-                       <Row>
-                               <Form.Group as={Col} sm={3} controlId="snes.proto">
-                                       <Form.Label>{t('snes.proto')}</Form.Label>
-                                       <Form.Select
-                                               isInvalid={!!(touched.proto && errors.proto)}
-                                               name="proto"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               value={values.proto || 'ws'}
-                                       >
-                                               <option value="ws">ws://</option>
-                                               <option value="wss">wss://</option>
-                                       </Form.Select>
-                                       {touched.proto && errors.proto ?
-                                               <Form.Control.Feedback type="invalid">
-                                                       {t(errors.proto)}
-                                               </Form.Control.Feedback>
-                                       : null}
-                               </Form.Group>
-                               <Form.Group as={Col} sm={6} controlId="snes.host">
-                                       <Form.Label>{t('snes.host')}</Form.Label>
-                                       <Form.Control
-                                               isInvalid={!!(touched.host && errors.host)}
-                                               name="host"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               type="text"
-                                               value={values.host || 'localhost'}
-                                       />
-                                       {touched.host && errors.host ?
-                                               <Form.Control.Feedback type="invalid">
-                                                       {t(errors.host)}
-                                               </Form.Control.Feedback>
-                                       : null}
-                               </Form.Group>
-                               <Form.Group as={Col} sm={3} controlId="snes.port">
-                                       <Form.Label>{t('snes.port')}</Form.Label>
-                                       <Form.Control
-                                               isInvalid={!!(touched.port && errors.port)}
-                                               min="1"
-                                               max="65665"
-                                               name="port"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               type="number"
-                                               value={values.port || 8080}
-                                       />
-                                       {touched.port && errors.port ?
-                                               <Form.Control.Feedback type="invalid">
-                                                       {t(errors.port)}
-                                               </Form.Control.Feedback>
-                                       : null}
-                               </Form.Group>
-                               <Form.Group as={Col} sm={12} controlId="snes.device">
-                                       <Form.Label>{t('snes.device')}</Form.Label>
-                                       <Form.Select
-                                               isInvalid={!!(touched.device && errors.device)}
-                                               name="device"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               value={values.device || ''}
-                                       >
-                                               <option value="">Auto</option>
-                                               {settings.device && !deviceList.includes(settings.device) ?
-                                                       <option value={settings.device}>{settings.device}</option>
-                                               : null}
-                                               {deviceList.map(device =>
-                                                       <option key={device} value={device}>{device}</option>
-                                               )}
-                                       </Form.Select>
-                                       {touched.device && errors.device ?
-                                               <Form.Control.Feedback type="invalid">
-                                                       {t(errors.device)}
-                                               </Form.Control.Feedback>
-                                       : null}
-                               </Form.Group>
-                       </Row>
-               </Modal.Body>
-               <Modal.Footer>
-                       {onCancel ?
-                               <Button onClick={onCancel} variant="secondary">
-                                       {t('button.cancel')}
-                               </Button>
-                       : null}
-                       <Button type="submit" variant="primary">
-                               {t('button.save')}
-                       </Button>
-               </Modal.Footer>
-       </Form>;
-};
-
-SettingsForm.propTypes = {
-       deviceList: PropTypes.arrayOf(PropTypes.string),
-       errors: PropTypes.shape({
-               device: PropTypes.string,
-               host: PropTypes.string,
-               port: PropTypes.string,
-               proto: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       settings: PropTypes.shape({
-               device: PropTypes.string,
-               host: PropTypes.string,
-               port: PropTypes.number,
-               proto: PropTypes.string,
-       }),
-       touched: PropTypes.shape({
-               device: PropTypes.bool,
-               host: PropTypes.bool,
-               port: PropTypes.bool,
-               proto: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               device: PropTypes.string,
-               host: PropTypes.string,
-               port: PropTypes.number,
-               proto: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'SettingsForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { onSubmit } = actions.props;
-               onSubmit(values);
-       },
-       mapPropsToValues: ({ settings }) => settings,
-       validationSchema: yup.object().shape({
-               device: yup.string(),
-               host: yup.string(),
-               port: yup.number().min(1).max(65665),
-               proto: yup.string(),
-       }),
-})(SettingsForm);
diff --git a/resources/js/components/snes/SettingsForm.jsx b/resources/js/components/snes/SettingsForm.jsx
new file mode 100644 (file)
index 0000000..7e05108
--- /dev/null
@@ -0,0 +1,161 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import yup from '../../schema/yup';
+
+const SettingsForm = ({
+       deviceList,
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       settings,
+       touched,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <Modal.Body>
+                       <Row>
+                               <Form.Group as={Col} sm={3} controlId="snes.proto">
+                                       <Form.Label>{t('snes.proto')}</Form.Label>
+                                       <Form.Select
+                                               isInvalid={!!(touched.proto && errors.proto)}
+                                               name="proto"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               value={values.proto || 'ws'}
+                                       >
+                                               <option value="ws">ws://</option>
+                                               <option value="wss">wss://</option>
+                                       </Form.Select>
+                                       {touched.proto && errors.proto ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.proto)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                               <Form.Group as={Col} sm={6} controlId="snes.host">
+                                       <Form.Label>{t('snes.host')}</Form.Label>
+                                       <Form.Control
+                                               isInvalid={!!(touched.host && errors.host)}
+                                               name="host"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               type="text"
+                                               value={values.host || 'localhost'}
+                                       />
+                                       {touched.host && errors.host ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.host)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                               <Form.Group as={Col} sm={3} controlId="snes.port">
+                                       <Form.Label>{t('snes.port')}</Form.Label>
+                                       <Form.Control
+                                               isInvalid={!!(touched.port && errors.port)}
+                                               min="1"
+                                               max="65665"
+                                               name="port"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               type="number"
+                                               value={values.port || 8080}
+                                       />
+                                       {touched.port && errors.port ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.port)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                               <Form.Group as={Col} sm={12} controlId="snes.device">
+                                       <Form.Label>{t('snes.device')}</Form.Label>
+                                       <Form.Select
+                                               isInvalid={!!(touched.device && errors.device)}
+                                               name="device"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               value={values.device || ''}
+                                       >
+                                               <option value="">Auto</option>
+                                               {settings.device && !deviceList.includes(settings.device) ?
+                                                       <option value={settings.device}>{settings.device}</option>
+                                               : null}
+                                               {deviceList.map(device =>
+                                                       <option key={device} value={device}>{device}</option>
+                                               )}
+                                       </Form.Select>
+                                       {touched.device && errors.device ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.device)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                       </Row>
+               </Modal.Body>
+               <Modal.Footer>
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.cancel')}
+                               </Button>
+                       : null}
+                       <Button type="submit" variant="primary">
+                               {t('button.save')}
+                       </Button>
+               </Modal.Footer>
+       </Form>;
+};
+
+SettingsForm.propTypes = {
+       deviceList: PropTypes.arrayOf(PropTypes.string),
+       errors: PropTypes.shape({
+               device: PropTypes.string,
+               host: PropTypes.string,
+               port: PropTypes.string,
+               proto: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       settings: PropTypes.shape({
+               device: PropTypes.string,
+               host: PropTypes.string,
+               port: PropTypes.number,
+               proto: PropTypes.string,
+       }),
+       touched: PropTypes.shape({
+               device: PropTypes.bool,
+               host: PropTypes.bool,
+               port: PropTypes.bool,
+               proto: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               device: PropTypes.string,
+               host: PropTypes.string,
+               port: PropTypes.number,
+               proto: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'SettingsForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { onSubmit } = actions.props;
+               onSubmit(values);
+       },
+       mapPropsToValues: ({ settings }) => settings,
+       validationSchema: yup.object().shape({
+               device: yup.string(),
+               host: yup.string(),
+               port: yup.number().min(1).max(65665),
+               proto: yup.string(),
+       }),
+})(SettingsForm);
diff --git a/resources/js/components/techniques/Detail.js b/resources/js/components/techniques/Detail.js
deleted file mode 100644 (file)
index 376a391..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button, Container } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import List from './List';
-import Outline from './Outline';
-import Requirements from './Requirements';
-import Rulesets from './Rulesets';
-import Icon from '../common/Icon';
-import RawHTML from '../common/RawHTML';
-import nl2br from '../../helpers/nl2br';
-import {
-       getRelations,
-       getTranslation,
-       hasRelations,
-       sorted,
-} from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const Detail = ({ actions, technique }) => {
-       const { t } = useTranslation();
-
-       return <Container as="article">
-               <div className="d-flex align-items-center justify-content-between">
-                       <h1>
-                               {getTranslation(technique, 'title', i18n.language)}
-                               {actions.editContent ?
-                                       <Button
-                                               className="ms-3"
-                                               onClick={() => actions.editContent(technique)}
-                                               size="sm"
-                                               title={t('button.edit')}
-                                               variant="outline-secondary"
-                                       >
-                                               <Icon.EDIT title="" />
-                                       </Button>
-                               : null}
-                       </h1>
-                       {technique && technique.rulesets ?
-                               <Rulesets technique={technique} />
-                       : null}
-               </div>
-               <Outline technique={technique} />
-               <Requirements technique={technique} />
-               <RawHTML html={getTranslation(technique, 'description', i18n.language)} />
-               {technique.chapters ? technique.chapters.map(chapter =>
-                       <section id={`c${chapter.id}`} key={`c${chapter.id}`}>
-                               {chapter.pivot.level ?
-                                       React.createElement(
-                                               `h${chapter.pivot.level}`,
-                                               {},
-                                               getTranslation(chapter, 'title', i18n.language),
-                                               actions.editContent ?
-                                                       <Button
-                                                               className="ms-3"
-                                                               onClick={() => actions.editContent(chapter)}
-                                                               size="sm"
-                                                               title={t('button.edit')}
-                                                               variant="outline-secondary"
-                                                       >
-                                                               <Icon.EDIT title="" />
-                                                       </Button>
-                                               : null,
-                                       )
-                               : null}
-                               <RawHTML html={getTranslation(chapter, 'description', i18n.language)} />
-                       </section>
-               ) : null}
-               {hasRelations(technique, 'related') ? <>
-                       <h2 className="mt-5">{i18n.t('techniques.seeAlso')}</h2>
-                       <List techniques={sorted(getRelations(technique, 'related'))} />
-               </> : null}
-               {getTranslation(technique, 'attribution', i18n.language) ?
-                       <Alert variant="dark">
-                               {nl2br(getTranslation(technique, 'attribution', i18n.language))}
-                       </Alert>
-               : null}
-       </Container>;
-};
-
-Detail.propTypes = {
-       actions: PropTypes.shape({
-               editContent: PropTypes.func,
-       }),
-       technique: PropTypes.shape({
-               chapters: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               description: PropTypes.string,
-               rulesets: PropTypes.shape({
-               }),
-               title: PropTypes.string,
-       }),
-};
-
-export default Detail;
diff --git a/resources/js/components/techniques/Detail.jsx b/resources/js/components/techniques/Detail.jsx
new file mode 100644 (file)
index 0000000..376a391
--- /dev/null
@@ -0,0 +1,96 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Container } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import List from './List';
+import Outline from './Outline';
+import Requirements from './Requirements';
+import Rulesets from './Rulesets';
+import Icon from '../common/Icon';
+import RawHTML from '../common/RawHTML';
+import nl2br from '../../helpers/nl2br';
+import {
+       getRelations,
+       getTranslation,
+       hasRelations,
+       sorted,
+} from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Detail = ({ actions, technique }) => {
+       const { t } = useTranslation();
+
+       return <Container as="article">
+               <div className="d-flex align-items-center justify-content-between">
+                       <h1>
+                               {getTranslation(technique, 'title', i18n.language)}
+                               {actions.editContent ?
+                                       <Button
+                                               className="ms-3"
+                                               onClick={() => actions.editContent(technique)}
+                                               size="sm"
+                                               title={t('button.edit')}
+                                               variant="outline-secondary"
+                                       >
+                                               <Icon.EDIT title="" />
+                                       </Button>
+                               : null}
+                       </h1>
+                       {technique && technique.rulesets ?
+                               <Rulesets technique={technique} />
+                       : null}
+               </div>
+               <Outline technique={technique} />
+               <Requirements technique={technique} />
+               <RawHTML html={getTranslation(technique, 'description', i18n.language)} />
+               {technique.chapters ? technique.chapters.map(chapter =>
+                       <section id={`c${chapter.id}`} key={`c${chapter.id}`}>
+                               {chapter.pivot.level ?
+                                       React.createElement(
+                                               `h${chapter.pivot.level}`,
+                                               {},
+                                               getTranslation(chapter, 'title', i18n.language),
+                                               actions.editContent ?
+                                                       <Button
+                                                               className="ms-3"
+                                                               onClick={() => actions.editContent(chapter)}
+                                                               size="sm"
+                                                               title={t('button.edit')}
+                                                               variant="outline-secondary"
+                                                       >
+                                                               <Icon.EDIT title="" />
+                                                       </Button>
+                                               : null,
+                                       )
+                               : null}
+                               <RawHTML html={getTranslation(chapter, 'description', i18n.language)} />
+                       </section>
+               ) : null}
+               {hasRelations(technique, 'related') ? <>
+                       <h2 className="mt-5">{i18n.t('techniques.seeAlso')}</h2>
+                       <List techniques={sorted(getRelations(technique, 'related'))} />
+               </> : null}
+               {getTranslation(technique, 'attribution', i18n.language) ?
+                       <Alert variant="dark">
+                               {nl2br(getTranslation(technique, 'attribution', i18n.language))}
+                       </Alert>
+               : null}
+       </Container>;
+};
+
+Detail.propTypes = {
+       actions: PropTypes.shape({
+               editContent: PropTypes.func,
+       }),
+       technique: PropTypes.shape({
+               chapters: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               description: PropTypes.string,
+               rulesets: PropTypes.shape({
+               }),
+               title: PropTypes.string,
+       }),
+};
+
+export default Detail;
diff --git a/resources/js/components/techniques/Dialog.js b/resources/js/components/techniques/Dialog.js
deleted file mode 100644 (file)
index eefa9d7..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Loading from '../common/Loading';
-import LanguageSwitcher from '../../app/LanguageSwitcher';
-
-const Form = React.lazy(() => import('./Form'));
-
-const Dialog = ({
-       content,
-       language,
-       onHide,
-       onSubmit,
-       show,
-}) => {
-       const { t } = useTranslation();
-
-       return <Modal onHide={onHide} show={show} size="lg">
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('content.edit')}
-                       </Modal.Title>
-                       <div className="mx-3">
-                               <LanguageSwitcher />
-                       </div>
-               </Modal.Header>
-               <React.Suspense fallback={<Loading />}>
-                       <Form
-                               content={content}
-                               language={language}
-                               onCancel={onHide}
-                               onSubmit={onSubmit}
-                       />
-               </React.Suspense>
-       </Modal>;
-};
-
-Dialog.propTypes = {
-       content: PropTypes.shape({
-       }),
-       language: PropTypes.string,
-       onHide: PropTypes.func,
-       onSubmit: PropTypes.func,
-       show: PropTypes.bool,
-};
-
-export default Dialog;
diff --git a/resources/js/components/techniques/Dialog.jsx b/resources/js/components/techniques/Dialog.jsx
new file mode 100644 (file)
index 0000000..eefa9d7
--- /dev/null
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Loading from '../common/Loading';
+import LanguageSwitcher from '../../app/LanguageSwitcher';
+
+const Form = React.lazy(() => import('./Form'));
+
+const Dialog = ({
+       content,
+       language,
+       onHide,
+       onSubmit,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal onHide={onHide} show={show} size="lg">
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('content.edit')}
+                       </Modal.Title>
+                       <div className="mx-3">
+                               <LanguageSwitcher />
+                       </div>
+               </Modal.Header>
+               <React.Suspense fallback={<Loading />}>
+                       <Form
+                               content={content}
+                               language={language}
+                               onCancel={onHide}
+                               onSubmit={onSubmit}
+                       />
+               </React.Suspense>
+       </Modal>;
+};
+
+Dialog.propTypes = {
+       content: PropTypes.shape({
+       }),
+       language: PropTypes.string,
+       onHide: PropTypes.func,
+       onSubmit: PropTypes.func,
+       show: PropTypes.bool,
+};
+
+export default Dialog;
diff --git a/resources/js/components/techniques/Form.js b/resources/js/components/techniques/Form.js
deleted file mode 100644 (file)
index b6dd3d5..0000000
+++ /dev/null
@@ -1,133 +0,0 @@
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import HTMLInput from '../common/HTMLInput';
-import { getTranslation } from '../../helpers/Technique';
-import yup from '../../schema/yup';
-
-const ContentForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       touched,
-       values,
-}) => {
-       const { t } = useTranslation();
-
-       return <Form noValidate onSubmit={handleSubmit}>
-               <Modal.Body>
-                       <Row>
-                               <Form.Group as={Col} md={6} controlId="content.title">
-                                       <Form.Label>{t('content.title')}</Form.Label>
-                                       <Form.Control
-                                               isInvalid={!!(touched.title && errors.title)}
-                                               name="title"
-                                               onBlur={handleBlur}
-                                               onChange={handleChange}
-                                               type="text"
-                                               value={values.title || ''}
-                                       />
-                               </Form.Group>
-                       </Row>
-                       <Form.Group controlId="content.short">
-                               <Form.Label>{t('content.short')}</Form.Label>
-                               <Form.Control
-                                       as="textarea"
-                                       isInvalid={!!(touched.short && errors.short)}
-                                       name="short"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       rows={3}
-                                       value={values.short || ''}
-                               />
-                       </Form.Group>
-                       <Form.Group controlId="content.description">
-                               <Form.Label>{t('content.description')}</Form.Label>
-                               <Form.Control
-                                       as={HTMLInput}
-                                       isInvalid={!!(touched.description && errors.description)}
-                                       name="description"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       rows={10}
-                                       value={values.description || ''}
-                               />
-                       </Form.Group>
-                       <Form.Group controlId="content.attribution">
-                               <Form.Label>{t('content.attribution')}</Form.Label>
-                               <Form.Control
-                                       as="textarea"
-                                       isInvalid={!!(touched.attribution && errors.attribution)}
-                                       name="attribution"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       rows={3}
-                                       value={values.attribution || ''}
-                               />
-                       </Form.Group>
-               </Modal.Body>
-               <Modal.Footer>
-                       {onCancel ?
-                               <Button onClick={onCancel} variant="secondary">
-                                       {t('button.cancel')}
-                               </Button>
-                       : null}
-                       <Button type="submit" variant="primary">
-                               {t('button.save')}
-                       </Button>
-               </Modal.Footer>
-       </Form>;
-};
-
-ContentForm.propTypes = {
-       errors: PropTypes.shape({
-               attribution: PropTypes.string,
-               description: PropTypes.string,
-               short: PropTypes.string,
-               title: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               attribution: PropTypes.bool,
-               description: PropTypes.bool,
-               short: PropTypes.bool,
-               title: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               attribution: PropTypes.string,
-               description: PropTypes.string,
-               short: PropTypes.string,
-               title: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'ContentForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { onSubmit } = actions.props;
-               await onSubmit(values);
-       },
-       mapPropsToValues: ({ content, language }) => ({
-               attribution: getTranslation(content, 'attribution', language),
-               description: getTranslation(content, 'description', language),
-               id: (content && content.id) || null,
-               language,
-               short: getTranslation(content, 'short', language),
-               title: getTranslation(content, 'title', language),
-       }),
-       validationSchema: yup.object().shape({
-               attribution: yup.string(),
-               description: yup.string(),
-               short: yup.string(),
-               title: yup.string(),
-       }),
-})(ContentForm);
diff --git a/resources/js/components/techniques/Form.jsx b/resources/js/components/techniques/Form.jsx
new file mode 100644 (file)
index 0000000..b6dd3d5
--- /dev/null
@@ -0,0 +1,133 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import HTMLInput from '../common/HTMLInput';
+import { getTranslation } from '../../helpers/Technique';
+import yup from '../../schema/yup';
+
+const ContentForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <Modal.Body>
+                       <Row>
+                               <Form.Group as={Col} md={6} controlId="content.title">
+                                       <Form.Label>{t('content.title')}</Form.Label>
+                                       <Form.Control
+                                               isInvalid={!!(touched.title && errors.title)}
+                                               name="title"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               type="text"
+                                               value={values.title || ''}
+                                       />
+                               </Form.Group>
+                       </Row>
+                       <Form.Group controlId="content.short">
+                               <Form.Label>{t('content.short')}</Form.Label>
+                               <Form.Control
+                                       as="textarea"
+                                       isInvalid={!!(touched.short && errors.short)}
+                                       name="short"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       rows={3}
+                                       value={values.short || ''}
+                               />
+                       </Form.Group>
+                       <Form.Group controlId="content.description">
+                               <Form.Label>{t('content.description')}</Form.Label>
+                               <Form.Control
+                                       as={HTMLInput}
+                                       isInvalid={!!(touched.description && errors.description)}
+                                       name="description"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       rows={10}
+                                       value={values.description || ''}
+                               />
+                       </Form.Group>
+                       <Form.Group controlId="content.attribution">
+                               <Form.Label>{t('content.attribution')}</Form.Label>
+                               <Form.Control
+                                       as="textarea"
+                                       isInvalid={!!(touched.attribution && errors.attribution)}
+                                       name="attribution"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       rows={3}
+                                       value={values.attribution || ''}
+                               />
+                       </Form.Group>
+               </Modal.Body>
+               <Modal.Footer>
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.cancel')}
+                               </Button>
+                       : null}
+                       <Button type="submit" variant="primary">
+                               {t('button.save')}
+                       </Button>
+               </Modal.Footer>
+       </Form>;
+};
+
+ContentForm.propTypes = {
+       errors: PropTypes.shape({
+               attribution: PropTypes.string,
+               description: PropTypes.string,
+               short: PropTypes.string,
+               title: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               attribution: PropTypes.bool,
+               description: PropTypes.bool,
+               short: PropTypes.bool,
+               title: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               attribution: PropTypes.string,
+               description: PropTypes.string,
+               short: PropTypes.string,
+               title: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'ContentForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { onSubmit } = actions.props;
+               await onSubmit(values);
+       },
+       mapPropsToValues: ({ content, language }) => ({
+               attribution: getTranslation(content, 'attribution', language),
+               description: getTranslation(content, 'description', language),
+               id: (content && content.id) || null,
+               language,
+               short: getTranslation(content, 'short', language),
+               title: getTranslation(content, 'title', language),
+       }),
+       validationSchema: yup.object().shape({
+               attribution: yup.string(),
+               description: yup.string(),
+               short: yup.string(),
+               title: yup.string(),
+       }),
+})(ContentForm);
diff --git a/resources/js/components/techniques/List.js b/resources/js/components/techniques/List.js
deleted file mode 100644 (file)
index aa80836..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Link } from 'react-router-dom';
-
-import Rulesets from './Rulesets';
-import {
-       getLink,
-       getTranslation,
-} from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const List = ({ techniques }) => <ul className="tech-list">
-       {techniques.map(tech =>
-               <li className="d-flex align-items-start justify-content-between" key={tech.id}>
-                       <div>
-                               <h2>
-                                       <Link to={getLink(tech)}>
-                                               {getTranslation(tech, 'title', i18n.language)}
-                                       </Link>
-                               </h2>
-                               <p>{getTranslation(tech, 'short', i18n.language)}</p>
-                       </div>
-                       {tech.rulesets ?
-                               <Rulesets technique={tech} />
-                       : null}
-               </li>
-       )}
-</ul>;
-
-List.propTypes = {
-       techniques: PropTypes.arrayOf(PropTypes.shape({
-               id: PropTypes.number,
-               name: PropTypes.string,
-       })),
-};
-
-export default List;
diff --git a/resources/js/components/techniques/List.jsx b/resources/js/components/techniques/List.jsx
new file mode 100644 (file)
index 0000000..aa80836
--- /dev/null
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import Rulesets from './Rulesets';
+import {
+       getLink,
+       getTranslation,
+} from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const List = ({ techniques }) => <ul className="tech-list">
+       {techniques.map(tech =>
+               <li className="d-flex align-items-start justify-content-between" key={tech.id}>
+                       <div>
+                               <h2>
+                                       <Link to={getLink(tech)}>
+                                               {getTranslation(tech, 'title', i18n.language)}
+                                       </Link>
+                               </h2>
+                               <p>{getTranslation(tech, 'short', i18n.language)}</p>
+                       </div>
+                       {tech.rulesets ?
+                               <Rulesets technique={tech} />
+                       : null}
+               </li>
+       )}
+</ul>;
+
+List.propTypes = {
+       techniques: PropTypes.arrayOf(PropTypes.shape({
+               id: PropTypes.number,
+               name: PropTypes.string,
+       })),
+};
+
+export default List;
diff --git a/resources/js/components/techniques/Outline.js b/resources/js/components/techniques/Outline.js
deleted file mode 100644 (file)
index ae18aa8..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { ListGroup } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import { getTranslation } from '../../helpers/Technique';
-import i18n from '../../i18n';
-
-const Outline = ({ technique }) => technique.chapters && technique.chapters.length ?
-       <aside className="tech-outline mb-3 ms-3">
-               <ListGroup>
-                       {technique.chapters.map(chapter => chapter.pivot.level ?
-                               <ListGroup.Item
-                                       action
-                                       href={`#c${chapter.id}`}
-                                       key={`c${chapter.id}`}
-                                       title={getTranslation(chapter, 'short', i18n.language) || null}
-                               >
-                                       {getTranslation(chapter, 'title', i18n.language)}
-                               </ListGroup.Item>
-                       : null)}
-               </ListGroup>
-       </aside>
-: null;
-
-Outline.propTypes = {
-       technique: PropTypes.shape({
-               chapters: PropTypes.arrayOf(PropTypes.shape({
-               })),
-       }),
-};
-
-export default withTranslation()(Outline);
diff --git a/resources/js/components/techniques/Outline.jsx b/resources/js/components/techniques/Outline.jsx
new file mode 100644 (file)
index 0000000..ae18aa8
--- /dev/null
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ListGroup } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import { getTranslation } from '../../helpers/Technique';
+import i18n from '../../i18n';
+
+const Outline = ({ technique }) => technique.chapters && technique.chapters.length ?
+       <aside className="tech-outline mb-3 ms-3">
+               <ListGroup>
+                       {technique.chapters.map(chapter => chapter.pivot.level ?
+                               <ListGroup.Item
+                                       action
+                                       href={`#c${chapter.id}`}
+                                       key={`c${chapter.id}`}
+                                       title={getTranslation(chapter, 'short', i18n.language) || null}
+                               >
+                                       {getTranslation(chapter, 'title', i18n.language)}
+                               </ListGroup.Item>
+                       : null)}
+               </ListGroup>
+       </aside>
+: null;
+
+Outline.propTypes = {
+       technique: PropTypes.shape({
+               chapters: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+};
+
+export default withTranslation()(Outline);
diff --git a/resources/js/components/techniques/Overview.js b/resources/js/components/techniques/Overview.js
deleted file mode 100644 (file)
index f82a877..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Container } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import List from './List';
-import TechFilter from './TechFilter';
-import i18n from '../../i18n';
-
-const Overview = ({
-       filter,
-       namespace,
-       setFilter,
-       techniques,
-       type,
-}) => <Container>
-       <div className="d-flex align-items-center justify-content-between">
-               <h1>{i18n.t(`${namespace}.heading`)}</h1>
-               {type === 'tech' ?
-                       <TechFilter filter={filter} setFilter={setFilter} />
-               : null}
-       </div>
-       <List techniques={techniques} />
-</Container>;
-
-Overview.propTypes = {
-       filter: PropTypes.shape({}),
-       namespace: PropTypes.string,
-       setFilter: PropTypes.func,
-       techniques: PropTypes.arrayOf(PropTypes.shape({
-       })),
-       type: PropTypes.string,
-};
-
-export default withTranslation()(Overview);
diff --git a/resources/js/components/techniques/Overview.jsx b/resources/js/components/techniques/Overview.jsx
new file mode 100644 (file)
index 0000000..f82a877
--- /dev/null
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import List from './List';
+import TechFilter from './TechFilter';
+import i18n from '../../i18n';
+
+const Overview = ({
+       filter,
+       namespace,
+       setFilter,
+       techniques,
+       type,
+}) => <Container>
+       <div className="d-flex align-items-center justify-content-between">
+               <h1>{i18n.t(`${namespace}.heading`)}</h1>
+               {type === 'tech' ?
+                       <TechFilter filter={filter} setFilter={setFilter} />
+               : null}
+       </div>
+       <List techniques={techniques} />
+</Container>;
+
+Overview.propTypes = {
+       filter: PropTypes.shape({}),
+       namespace: PropTypes.string,
+       setFilter: PropTypes.func,
+       techniques: PropTypes.arrayOf(PropTypes.shape({
+       })),
+       type: PropTypes.string,
+};
+
+export default withTranslation()(Overview);
diff --git a/resources/js/components/techniques/Requirement.js b/resources/js/components/techniques/Requirement.js
deleted file mode 100644 (file)
index 5faa372..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import ZeldaIcon from '../common/ZeldaIcon';
-
-const Requirement = ({ requirement }) =>
-       <div className="requirement">
-               {requirement.map(r =>
-                       <ZeldaIcon key={r} name={r} />
-               )}
-       </div>;
-
-Requirement.propTypes = {
-       requirement: PropTypes.arrayOf(PropTypes.string),
-};
-
-export default Requirement;
diff --git a/resources/js/components/techniques/Requirement.jsx b/resources/js/components/techniques/Requirement.jsx
new file mode 100644 (file)
index 0000000..5faa372
--- /dev/null
@@ -0,0 +1,17 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import ZeldaIcon from '../common/ZeldaIcon';
+
+const Requirement = ({ requirement }) =>
+       <div className="requirement">
+               {requirement.map(r =>
+                       <ZeldaIcon key={r} name={r} />
+               )}
+       </div>;
+
+Requirement.propTypes = {
+       requirement: PropTypes.arrayOf(PropTypes.string),
+};
+
+export default Requirement;
diff --git a/resources/js/components/techniques/Requirements.js b/resources/js/components/techniques/Requirements.js
deleted file mode 100644 (file)
index 7919b58..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import Requirement from './Requirement';
-
-const Requirements = ({ technique }) => {
-       const { t } = useTranslation();
-
-       if (!technique.requirements || !technique.requirements.length) {
-               return null;
-       }
-
-       return <div className="tech-requirements mb-3">
-               {t('techniques.requirements')}
-               <ul>
-                       {technique.requirements.map((r, i) =>
-                               <li key={i}>
-                                       <Requirement requirement={r} />
-                               </li>
-                       )}
-               </ul>
-       </div>;
-};
-
-Requirements.propTypes = {
-       technique: PropTypes.shape({
-               requirements: PropTypes.arrayOf(
-                       PropTypes.arrayOf(PropTypes.string),
-               ),
-       }),
-};
-
-export default Requirements;
diff --git a/resources/js/components/techniques/Requirements.jsx b/resources/js/components/techniques/Requirements.jsx
new file mode 100644 (file)
index 0000000..7919b58
--- /dev/null
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Requirement from './Requirement';
+
+const Requirements = ({ technique }) => {
+       const { t } = useTranslation();
+
+       if (!technique.requirements || !technique.requirements.length) {
+               return null;
+       }
+
+       return <div className="tech-requirements mb-3">
+               {t('techniques.requirements')}
+               <ul>
+                       {technique.requirements.map((r, i) =>
+                               <li key={i}>
+                                       <Requirement requirement={r} />
+                               </li>
+                       )}
+               </ul>
+       </div>;
+};
+
+Requirements.propTypes = {
+       technique: PropTypes.shape({
+               requirements: PropTypes.arrayOf(
+                       PropTypes.arrayOf(PropTypes.string),
+               ),
+       }),
+};
+
+export default Requirements;
diff --git a/resources/js/components/techniques/Rulesets.js b/resources/js/components/techniques/Rulesets.js
deleted file mode 100644 (file)
index 6d4ee50..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import Icon from '../common/Icon';
-
-const Rulesets = ({ technique }) => {
-       const { t } = useTranslation();
-
-       return <div className="ruleset-box">
-               {['competitive', 'owg', 'mg', 'nl'].map(r =>
-                       <span key={r} title={t(`techniques.rulesetDescriptions.${r}`)}>
-                               {technique && technique.rulesets && technique.rulesets[r] ?
-                                       <Icon.ALLOWED className="text-success" />
-                               : null}
-                               {technique && technique.rulesets && !technique.rulesets[r] ?
-                                       <Icon.FORBIDDEN className="text-danger" />
-                               : null}
-                               {!technique || !technique.rulesets ?
-                                       <Icon.UNKNOWN />
-                               : null}
-                               {' '}
-                               {t(`techniques.rulesetCodes.${r}`)}
-                       </span>
-               )}
-       </div>;
-};
-
-Rulesets.propTypes = {
-       technique: PropTypes.shape({
-               rulesets: PropTypes.shape({
-               }),
-       }),
-};
-
-export default Rulesets;
diff --git a/resources/js/components/techniques/Rulesets.jsx b/resources/js/components/techniques/Rulesets.jsx
new file mode 100644 (file)
index 0000000..6d4ee50
--- /dev/null
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+
+const Rulesets = ({ technique }) => {
+       const { t } = useTranslation();
+
+       return <div className="ruleset-box">
+               {['competitive', 'owg', 'mg', 'nl'].map(r =>
+                       <span key={r} title={t(`techniques.rulesetDescriptions.${r}`)}>
+                               {technique && technique.rulesets && technique.rulesets[r] ?
+                                       <Icon.ALLOWED className="text-success" />
+                               : null}
+                               {technique && technique.rulesets && !technique.rulesets[r] ?
+                                       <Icon.FORBIDDEN className="text-danger" />
+                               : null}
+                               {!technique || !technique.rulesets ?
+                                       <Icon.UNKNOWN />
+                               : null}
+                               {' '}
+                               {t(`techniques.rulesetCodes.${r}`)}
+                       </span>
+               )}
+       </div>;
+};
+
+Rulesets.propTypes = {
+       technique: PropTypes.shape({
+               rulesets: PropTypes.shape({
+               }),
+       }),
+};
+
+export default Rulesets;
diff --git a/resources/js/components/techniques/TechFilter.js b/resources/js/components/techniques/TechFilter.js
deleted file mode 100644 (file)
index 008288a..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Form } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-const TechFilter = ({ filter, setFilter }) => {
-       const { t } = useTranslation();
-
-       const handleChange = React.useCallback(e => {
-               if (e.target.name.startsWith('ruleset.')) {
-                       const r = e.target.name.substring(8);
-                       setFilter({
-                               ...filter,
-                               ruleset: {
-                                       ...filter.ruleset || {},
-                                       [r]: e.target.checked ? '1' : '0',
-                               },
-                       });
-               }
-       }, [filter]);
-
-       return <div className="tech-filter">
-               <div>{t('techniques.rulesetFilterHeading')}</div>
-               <div className="ruleset-box">
-                       {['competitive', 'owg', 'mg', 'nl'].map(r =>
-                               <Form.Check
-                                       checked={!!(filter && filter.ruleset && filter.ruleset[r] === '1')}
-                                       key={r}
-                                       id={`tech.filter.ruleset.${r}`}
-                                       name={`ruleset.${r}`}
-                                       label={t(`techniques.rulesetCodes.${r}`)}
-                                       onChange={handleChange}
-                                       title={t(`techniques.rulesetDescriptions.${r}`)}
-                                       type="checkbox"
-                               />
-                       )}
-               </div>
-       </div>;
-};
-
-TechFilter.propTypes = {
-       filter: PropTypes.shape({
-               ruleset: PropTypes.shape({
-               }),
-       }),
-       setFilter: PropTypes.func,
-};
-
-export default TechFilter;
diff --git a/resources/js/components/techniques/TechFilter.jsx b/resources/js/components/techniques/TechFilter.jsx
new file mode 100644 (file)
index 0000000..008288a
--- /dev/null
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const TechFilter = ({ filter, setFilter }) => {
+       const { t } = useTranslation();
+
+       const handleChange = React.useCallback(e => {
+               if (e.target.name.startsWith('ruleset.')) {
+                       const r = e.target.name.substring(8);
+                       setFilter({
+                               ...filter,
+                               ruleset: {
+                                       ...filter.ruleset || {},
+                                       [r]: e.target.checked ? '1' : '0',
+                               },
+                       });
+               }
+       }, [filter]);
+
+       return <div className="tech-filter">
+               <div>{t('techniques.rulesetFilterHeading')}</div>
+               <div className="ruleset-box">
+                       {['competitive', 'owg', 'mg', 'nl'].map(r =>
+                               <Form.Check
+                                       checked={!!(filter && filter.ruleset && filter.ruleset[r] === '1')}
+                                       key={r}
+                                       id={`tech.filter.ruleset.${r}`}
+                                       name={`ruleset.${r}`}
+                                       label={t(`techniques.rulesetCodes.${r}`)}
+                                       onChange={handleChange}
+                                       title={t(`techniques.rulesetDescriptions.${r}`)}
+                                       type="checkbox"
+                               />
+                       )}
+               </div>
+       </div>;
+};
+
+TechFilter.propTypes = {
+       filter: PropTypes.shape({
+               ruleset: PropTypes.shape({
+               }),
+       }),
+       setFilter: PropTypes.func,
+};
+
+export default TechFilter;
diff --git a/resources/js/components/tournament/ApplyButton.js b/resources/js/components/tournament/ApplyButton.js
deleted file mode 100644 (file)
index 8bbd98e..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import Icon from '../common/Icon';
-import { isApplicant, isDeniedApplicant, isRunner, mayApply } from '../../helpers/permissions';
-import { useUser } from '../../hooks/user';
-import i18n from '../../i18n';
-
-const apply = async tournament => {
-       try {
-               await axios.post(`/api/tournaments/${tournament.id}/apply`);
-               toastr.success(i18n.t('tournaments.applySuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('tournaments.applyError'));
-       }
-};
-
-const getTitle = (user, tournament) => {
-       if (isDeniedApplicant(user, tournament)) {
-               return i18n.t('tournaments.applicationDenied');
-       }
-       if (isApplicant(user, tournament)) {
-               return i18n.t('tournaments.applicationPending');
-       }
-       return i18n.t('tournaments.apply');
-};
-
-const ApplyButton = ({ tournament }) => {
-       const { user } = useUser();
-
-       if (!user || !tournament.accept_applications || isRunner(user, tournament)) return null;
-
-       return <span className="d-inline-block" title={getTitle(user, tournament)}>
-               <Button
-                       disabled={!mayApply(user, tournament)}
-                       onClick={() => apply(tournament)}
-                       variant="primary"
-               >
-                       <Icon.APPLY title="" />
-               </Button>
-       </span>;
-};
-
-ApplyButton.propTypes = {
-       tournament: PropTypes.shape({
-               accept_applications: PropTypes.bool,
-               id: PropTypes.number,
-       }),
-};
-
-export default withTranslation()(ApplyButton);
diff --git a/resources/js/components/tournament/ApplyButton.jsx b/resources/js/components/tournament/ApplyButton.jsx
new file mode 100644 (file)
index 0000000..8bbd98e
--- /dev/null
@@ -0,0 +1,55 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import Icon from '../common/Icon';
+import { isApplicant, isDeniedApplicant, isRunner, mayApply } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+import i18n from '../../i18n';
+
+const apply = async tournament => {
+       try {
+               await axios.post(`/api/tournaments/${tournament.id}/apply`);
+               toastr.success(i18n.t('tournaments.applySuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('tournaments.applyError'));
+       }
+};
+
+const getTitle = (user, tournament) => {
+       if (isDeniedApplicant(user, tournament)) {
+               return i18n.t('tournaments.applicationDenied');
+       }
+       if (isApplicant(user, tournament)) {
+               return i18n.t('tournaments.applicationPending');
+       }
+       return i18n.t('tournaments.apply');
+};
+
+const ApplyButton = ({ tournament }) => {
+       const { user } = useUser();
+
+       if (!user || !tournament.accept_applications || isRunner(user, tournament)) return null;
+
+       return <span className="d-inline-block" title={getTitle(user, tournament)}>
+               <Button
+                       disabled={!mayApply(user, tournament)}
+                       onClick={() => apply(tournament)}
+                       variant="primary"
+               >
+                       <Icon.APPLY title="" />
+               </Button>
+       </span>;
+};
+
+ApplyButton.propTypes = {
+       tournament: PropTypes.shape({
+               accept_applications: PropTypes.bool,
+               id: PropTypes.number,
+       }),
+};
+
+export default withTranslation()(ApplyButton);
diff --git a/resources/js/components/tournament/Detail.js b/resources/js/components/tournament/Detail.js
deleted file mode 100644 (file)
index a931865..0000000
+++ /dev/null
@@ -1,166 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Container, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import ApplyButton from './ApplyButton';
-import Scoreboard from './Scoreboard';
-import ScoreChartButton from './ScoreChartButton';
-import SettingsButton from './SettingsButton';
-import ApplicationsButton from '../applications/Button';
-import Icon from '../common/Icon';
-import RawHTML from '../common/RawHTML';
-import Protocol from '../protocol/Protocol';
-import Rounds from '../rounds/List';
-import Box from '../users/Box';
-import {
-       isRunner,
-       mayAddRounds,
-       mayUpdateTournament,
-       mayViewProtocol,
-} from '../../helpers/permissions';
-import { getTranslation } from '../../helpers/Technique';
-import {
-       getTournamentAdmins,
-       getTournamentMonitors,
-       hasRunners,
-       hasScoreboard,
-       hasTournamentAdmins,
-       hasTournamentMonitors,
-} from '../../helpers/Tournament';
-import { useUser } from '../../hooks/user';
-import i18n from '../../i18n';
-
-const getClassName = (tournament, user) => {
-       const classNames = ['tournament'];
-       if (tournament.locked) {
-               classNames.push('is-locked');
-       } else {
-               classNames.push('is-active');
-       }
-       if (isRunner(user, tournament)) {
-               classNames.push('is-runner');
-       }
-       return classNames.join(' ');
-};
-
-const Detail = ({
-       actions,
-       tournament,
-}) => {
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       return <Container className={getClassName(tournament, user)} fluid>
-               <Row>
-                       <Col lg={8} xl={9}>
-                               <div className="d-flex align-items-center justify-content-between">
-                                       <h1>
-                                               {(tournament.description
-                                                       && getTranslation(tournament.description, 'title', i18n.language))
-                                                       || tournament.title}
-                                       </h1>
-                                       <div className="button-bar">
-                                               {tournament.description && actions.editContent ?
-                                                       <Button
-                                                               className="ms-3"
-                                                               onClick={() => actions.editContent(tournament.description)}
-                                                               title={t('button.edit')}
-                                                               variant="outline-secondary"
-                                                       >
-                                                               <Icon.EDIT title="" />
-                                                       </Button>
-                                               : null}
-                                               <ApplicationsButton tournament={tournament} />
-                                               <ApplyButton tournament={tournament} />
-                                               {mayUpdateTournament(user, tournament) ?
-                                                       <SettingsButton tournament={tournament} />
-                                               : null}
-                                               {mayViewProtocol(user, tournament) ?
-                                                       <Protocol id={tournament.id} />
-                                               : null}
-                                       </div>
-                               </div>
-                               {tournament.description ?
-                                       <RawHTML
-                                               html={getTranslation(tournament.description, 'description', i18n.language)}
-                                       />
-                               : null}
-                       </Col>
-               </Row>
-               <Row>
-                       <Col lg={{ order: 2, span: 4 }} xl={{ order: 2, span: 3 }}>
-                               <div className="tournament-sidebar">
-                                       {hasScoreboard(tournament) ? <>
-                                               <div className="d-flex align-items-center justify-content-between">
-                                                       <h2>{t('tournaments.scoreboard')}</h2>
-                                                       {hasRunners(tournament) && tournament.rounds.length > 2 ?
-                                                               <ScoreChartButton tournament={tournament} />
-                                                       : null}
-                                               </div>
-                                               {hasRunners(tournament) ?
-                                                       <Scoreboard tournament={tournament} />
-                                               : null}
-                                       </> : null}
-                                       {hasTournamentAdmins(tournament) ?
-                                               <>
-                                                       <div className="d-flex align-items-center justify-content-between">
-                                                               <h2>{t('tournaments.admins')}</h2>
-                                                       </div>
-                                                       {getTournamentAdmins(tournament).map(p =>
-                                                               <p key={p.id}><Box user={p.user} /></p>
-                                                       )}
-                                               </>
-                                       : null}
-                                       {hasTournamentMonitors(tournament) ?
-                                               <>
-                                                       <div className="d-flex align-items-center justify-content-between">
-                                                               <h2>{t('tournaments.monitors')}</h2>
-                                                       </div>
-                                                       {getTournamentMonitors(tournament).map(p =>
-                                                               <p key={p.id}><Box user={p.user} /></p>
-                                                       )}
-                                               </>
-                                       : null}
-                               </div>
-                       </Col>
-                       <Col lg={{ order: 1, span: 8 }} xl={{ order: 1, span: 9 }}>
-                               <div className="d-flex align-items-center justify-content-between">
-                                       <h2>{t('rounds.heading')}</h2>
-                                       {actions.addRound && mayAddRounds(user, tournament) ?
-                                               <Button onClick={actions.addRound}>
-                                                       {t('rounds.new')}
-                                               </Button>
-                                       : null}
-                               </div>
-                               {tournament.rounds ?
-                                       <Rounds
-                                               loadMore={actions.moreRounds}
-                                               rounds={tournament.rounds}
-                                               tournament={tournament}
-                                       />
-                               : null}
-                       </Col>
-               </Row>
-       </Container>;
-};
-
-Detail.propTypes = {
-       actions: PropTypes.shape({
-               addRound: PropTypes.func,
-               editContent: PropTypes.func,
-               moreRounds: PropTypes.func,
-       }).isRequired,
-       tournament: PropTypes.shape({
-               description: PropTypes.shape({
-               }),
-               id: PropTypes.number,
-               participants: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               rounds: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               title: PropTypes.string,
-       }),
-};
-
-export default Detail;
diff --git a/resources/js/components/tournament/Detail.jsx b/resources/js/components/tournament/Detail.jsx
new file mode 100644 (file)
index 0000000..a931865
--- /dev/null
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Container, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import ApplyButton from './ApplyButton';
+import Scoreboard from './Scoreboard';
+import ScoreChartButton from './ScoreChartButton';
+import SettingsButton from './SettingsButton';
+import ApplicationsButton from '../applications/Button';
+import Icon from '../common/Icon';
+import RawHTML from '../common/RawHTML';
+import Protocol from '../protocol/Protocol';
+import Rounds from '../rounds/List';
+import Box from '../users/Box';
+import {
+       isRunner,
+       mayAddRounds,
+       mayUpdateTournament,
+       mayViewProtocol,
+} from '../../helpers/permissions';
+import { getTranslation } from '../../helpers/Technique';
+import {
+       getTournamentAdmins,
+       getTournamentMonitors,
+       hasRunners,
+       hasScoreboard,
+       hasTournamentAdmins,
+       hasTournamentMonitors,
+} from '../../helpers/Tournament';
+import { useUser } from '../../hooks/user';
+import i18n from '../../i18n';
+
+const getClassName = (tournament, user) => {
+       const classNames = ['tournament'];
+       if (tournament.locked) {
+               classNames.push('is-locked');
+       } else {
+               classNames.push('is-active');
+       }
+       if (isRunner(user, tournament)) {
+               classNames.push('is-runner');
+       }
+       return classNames.join(' ');
+};
+
+const Detail = ({
+       actions,
+       tournament,
+}) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       return <Container className={getClassName(tournament, user)} fluid>
+               <Row>
+                       <Col lg={8} xl={9}>
+                               <div className="d-flex align-items-center justify-content-between">
+                                       <h1>
+                                               {(tournament.description
+                                                       && getTranslation(tournament.description, 'title', i18n.language))
+                                                       || tournament.title}
+                                       </h1>
+                                       <div className="button-bar">
+                                               {tournament.description && actions.editContent ?
+                                                       <Button
+                                                               className="ms-3"
+                                                               onClick={() => actions.editContent(tournament.description)}
+                                                               title={t('button.edit')}
+                                                               variant="outline-secondary"
+                                                       >
+                                                               <Icon.EDIT title="" />
+                                                       </Button>
+                                               : null}
+                                               <ApplicationsButton tournament={tournament} />
+                                               <ApplyButton tournament={tournament} />
+                                               {mayUpdateTournament(user, tournament) ?
+                                                       <SettingsButton tournament={tournament} />
+                                               : null}
+                                               {mayViewProtocol(user, tournament) ?
+                                                       <Protocol id={tournament.id} />
+                                               : null}
+                                       </div>
+                               </div>
+                               {tournament.description ?
+                                       <RawHTML
+                                               html={getTranslation(tournament.description, 'description', i18n.language)}
+                                       />
+                               : null}
+                       </Col>
+               </Row>
+               <Row>
+                       <Col lg={{ order: 2, span: 4 }} xl={{ order: 2, span: 3 }}>
+                               <div className="tournament-sidebar">
+                                       {hasScoreboard(tournament) ? <>
+                                               <div className="d-flex align-items-center justify-content-between">
+                                                       <h2>{t('tournaments.scoreboard')}</h2>
+                                                       {hasRunners(tournament) && tournament.rounds.length > 2 ?
+                                                               <ScoreChartButton tournament={tournament} />
+                                                       : null}
+                                               </div>
+                                               {hasRunners(tournament) ?
+                                                       <Scoreboard tournament={tournament} />
+                                               : null}
+                                       </> : null}
+                                       {hasTournamentAdmins(tournament) ?
+                                               <>
+                                                       <div className="d-flex align-items-center justify-content-between">
+                                                               <h2>{t('tournaments.admins')}</h2>
+                                                       </div>
+                                                       {getTournamentAdmins(tournament).map(p =>
+                                                               <p key={p.id}><Box user={p.user} /></p>
+                                                       )}
+                                               </>
+                                       : null}
+                                       {hasTournamentMonitors(tournament) ?
+                                               <>
+                                                       <div className="d-flex align-items-center justify-content-between">
+                                                               <h2>{t('tournaments.monitors')}</h2>
+                                                       </div>
+                                                       {getTournamentMonitors(tournament).map(p =>
+                                                               <p key={p.id}><Box user={p.user} /></p>
+                                                       )}
+                                               </>
+                                       : null}
+                               </div>
+                       </Col>
+                       <Col lg={{ order: 1, span: 8 }} xl={{ order: 1, span: 9 }}>
+                               <div className="d-flex align-items-center justify-content-between">
+                                       <h2>{t('rounds.heading')}</h2>
+                                       {actions.addRound && mayAddRounds(user, tournament) ?
+                                               <Button onClick={actions.addRound}>
+                                                       {t('rounds.new')}
+                                               </Button>
+                                       : null}
+                               </div>
+                               {tournament.rounds ?
+                                       <Rounds
+                                               loadMore={actions.moreRounds}
+                                               rounds={tournament.rounds}
+                                               tournament={tournament}
+                                       />
+                               : null}
+                       </Col>
+               </Row>
+       </Container>;
+};
+
+Detail.propTypes = {
+       actions: PropTypes.shape({
+               addRound: PropTypes.func,
+               editContent: PropTypes.func,
+               moreRounds: PropTypes.func,
+       }).isRequired,
+       tournament: PropTypes.shape({
+               description: PropTypes.shape({
+               }),
+               id: PropTypes.number,
+               participants: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               rounds: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               title: PropTypes.string,
+       }),
+};
+
+export default Detail;
diff --git a/resources/js/components/tournament/DiscordForm.js b/resources/js/components/tournament/DiscordForm.js
deleted file mode 100644 (file)
index d896504..0000000
+++ /dev/null
@@ -1,108 +0,0 @@
-import axios from 'axios';
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Form } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import DiscordChannelSelect from '../common/DiscordChannelSelect';
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import i18n from '../../i18n';
-import yup from '../../schema/yup';
-
-const DiscordForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       touched,
-       tournament,
-       values,
-}) =>
-<Form noValidate onSubmit={handleSubmit}>
-       <fieldset>
-               <legend>{i18n.t('tournaments.discordSettings')}</legend>
-               <Form.Group controlId="tournament.discord_round_category">
-                       <Form.Label>
-                               {i18n.t('tournaments.discordRoundCategory')}
-                       </Form.Label>
-                       <DiscordChannelSelect
-                               guild={tournament.discord}
-                               isInvalid={!!(touched.round_category && errors.round_category)}
-                               name="round_category"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               types={[4]}
-                               value={values.round_category || ''}
-                       />
-               </Form.Group>
-               <Form.Group controlId="tournament.discord_round_template">
-                       <Form.Label>
-                               {i18n.t('tournaments.discordRoundTemplate')}
-                       </Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.round_template && errors.round_template)}
-                               name="round_template"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.round_template || ''}
-                       />
-               </Form.Group>
-               <Button className="mt-3" type="submit" variant="primary">
-                       {i18n.t('button.save')}
-               </Button>
-       </fieldset>
-</Form>;
-
-DiscordForm.propTypes = {
-       errors: PropTypes.shape({
-               round_category: PropTypes.string,
-               round_template: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       touched: PropTypes.shape({
-               round_category: PropTypes.bool,
-               round_template: PropTypes.bool,
-       }),
-       tournament: PropTypes.shape({
-               discord: PropTypes.string,
-       }),
-       values: PropTypes.shape({
-               round_category: PropTypes.string,
-               round_template: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'DiscordForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { round_category, round_template } = values;
-               const { setErrors } = actions;
-               const { tournament } = actions.props;
-               try {
-                       await axios.post(`/api/tournaments/${tournament.id}/discord-settings`, {
-                               round_category,
-                               round_template,
-                       });
-                       toastr.success(i18n.t('tournaments.discordSettingsSuccess'));
-               } catch (e) {
-                       toastr.error(i18n.t('tournaments.discordSettingsError'));
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ tournament }) => ({
-               round_category: tournament.discord_round_category || '',
-               round_template: tournament.discord_round_template || '',
-       }),
-       validationSchema: yup.object().shape({
-               round_category: yup.string(),
-               round_template: yup.string(),
-       }),
-})(withTranslation()(DiscordForm));
diff --git a/resources/js/components/tournament/DiscordForm.jsx b/resources/js/components/tournament/DiscordForm.jsx
new file mode 100644 (file)
index 0000000..d896504
--- /dev/null
@@ -0,0 +1,108 @@
+import axios from 'axios';
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import DiscordChannelSelect from '../common/DiscordChannelSelect';
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import i18n from '../../i18n';
+import yup from '../../schema/yup';
+
+const DiscordForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       touched,
+       tournament,
+       values,
+}) =>
+<Form noValidate onSubmit={handleSubmit}>
+       <fieldset>
+               <legend>{i18n.t('tournaments.discordSettings')}</legend>
+               <Form.Group controlId="tournament.discord_round_category">
+                       <Form.Label>
+                               {i18n.t('tournaments.discordRoundCategory')}
+                       </Form.Label>
+                       <DiscordChannelSelect
+                               guild={tournament.discord}
+                               isInvalid={!!(touched.round_category && errors.round_category)}
+                               name="round_category"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               types={[4]}
+                               value={values.round_category || ''}
+                       />
+               </Form.Group>
+               <Form.Group controlId="tournament.discord_round_template">
+                       <Form.Label>
+                               {i18n.t('tournaments.discordRoundTemplate')}
+                       </Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.round_template && errors.round_template)}
+                               name="round_template"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.round_template || ''}
+                       />
+               </Form.Group>
+               <Button className="mt-3" type="submit" variant="primary">
+                       {i18n.t('button.save')}
+               </Button>
+       </fieldset>
+</Form>;
+
+DiscordForm.propTypes = {
+       errors: PropTypes.shape({
+               round_category: PropTypes.string,
+               round_template: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       touched: PropTypes.shape({
+               round_category: PropTypes.bool,
+               round_template: PropTypes.bool,
+       }),
+       tournament: PropTypes.shape({
+               discord: PropTypes.string,
+       }),
+       values: PropTypes.shape({
+               round_category: PropTypes.string,
+               round_template: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'DiscordForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { round_category, round_template } = values;
+               const { setErrors } = actions;
+               const { tournament } = actions.props;
+               try {
+                       await axios.post(`/api/tournaments/${tournament.id}/discord-settings`, {
+                               round_category,
+                               round_template,
+                       });
+                       toastr.success(i18n.t('tournaments.discordSettingsSuccess'));
+               } catch (e) {
+                       toastr.error(i18n.t('tournaments.discordSettingsError'));
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ tournament }) => ({
+               round_category: tournament.discord_round_category || '',
+               round_template: tournament.discord_round_template || '',
+       }),
+       validationSchema: yup.object().shape({
+               round_category: yup.string(),
+               round_template: yup.string(),
+       }),
+})(withTranslation()(DiscordForm));
diff --git a/resources/js/components/tournament/ScoreChart.js b/resources/js/components/tournament/ScoreChart.js
deleted file mode 100644 (file)
index b63c81e..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
-
-import { getUserName } from '../../helpers/Participant';
-import { getRunners, getScoreTable } from '../../helpers/Tournament';
-
-const COLORS = [
-       '#7cb5ec',
-       '#434348',
-       '#90ed7d',
-       '#f7a35c',
-       '#8085e9',
-       '#f15c80',
-       '#e4d354',
-       '#2b908f',
-       '#f45b5b',
-       '#91e8e1',
-];
-
-const ScoreChart = ({
-       tournament,
-}) =>
-<ResponsiveContainer height="100%" width="100%">
-       <LineChart data={getScoreTable(tournament)} height={720} width={1280}>
-               <XAxis dataKey="number" />
-               <YAxis />
-               <Tooltip />
-               <Legend />
-               {getRunners(tournament).map((runner, index) =>
-                       <Line
-                               dataKey={getUserName(runner)}
-                               key={runner.id}
-                               stroke={COLORS[index % COLORS.length]}
-                               type="monotone"
-                       />
-               )}
-       </LineChart>
-</ResponsiveContainer>;
-
-ScoreChart.propTypes = {
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default ScoreChart;
diff --git a/resources/js/components/tournament/ScoreChart.jsx b/resources/js/components/tournament/ScoreChart.jsx
new file mode 100644 (file)
index 0000000..b63c81e
--- /dev/null
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
+
+import { getUserName } from '../../helpers/Participant';
+import { getRunners, getScoreTable } from '../../helpers/Tournament';
+
+const COLORS = [
+       '#7cb5ec',
+       '#434348',
+       '#90ed7d',
+       '#f7a35c',
+       '#8085e9',
+       '#f15c80',
+       '#e4d354',
+       '#2b908f',
+       '#f45b5b',
+       '#91e8e1',
+];
+
+const ScoreChart = ({
+       tournament,
+}) =>
+<ResponsiveContainer height="100%" width="100%">
+       <LineChart data={getScoreTable(tournament)} height={720} width={1280}>
+               <XAxis dataKey="number" />
+               <YAxis />
+               <Tooltip />
+               <Legend />
+               {getRunners(tournament).map((runner, index) =>
+                       <Line
+                               dataKey={getUserName(runner)}
+                               key={runner.id}
+                               stroke={COLORS[index % COLORS.length]}
+                               type="monotone"
+                       />
+               )}
+       </LineChart>
+</ResponsiveContainer>;
+
+ScoreChart.propTypes = {
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default ScoreChart;
diff --git a/resources/js/components/tournament/ScoreChartButton.js b/resources/js/components/tournament/ScoreChartButton.js
deleted file mode 100644 (file)
index 9074487..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import ScoreChartDialog from './ScoreChartDialog';
-import Icon from '../common/Icon';
-import i18n from '../../i18n';
-
-const ScoreChartButton = ({ tournament }) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       return <>
-               <Button
-                       onClick={() => setShowDialog(true)}
-                       title={i18n.t('button.chart')}
-                       variant="info"
-               >
-                       <Icon.CHART title="" />
-               </Button>
-               <ScoreChartDialog
-                       onHide={() => setShowDialog(false)}
-                       tournament={tournament}
-                       show={showDialog}
-               />
-       </>;
-};
-
-ScoreChartButton.propTypes = {
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(ScoreChartButton);
diff --git a/resources/js/components/tournament/ScoreChartButton.jsx b/resources/js/components/tournament/ScoreChartButton.jsx
new file mode 100644 (file)
index 0000000..9074487
--- /dev/null
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import ScoreChartDialog from './ScoreChartDialog';
+import Icon from '../common/Icon';
+import i18n from '../../i18n';
+
+const ScoreChartButton = ({ tournament }) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       return <>
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       title={i18n.t('button.chart')}
+                       variant="info"
+               >
+                       <Icon.CHART title="" />
+               </Button>
+               <ScoreChartDialog
+                       onHide={() => setShowDialog(false)}
+                       tournament={tournament}
+                       show={showDialog}
+               />
+       </>;
+};
+
+ScoreChartButton.propTypes = {
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(ScoreChartButton);
diff --git a/resources/js/components/tournament/ScoreChartDialog.js b/resources/js/components/tournament/ScoreChartDialog.js
deleted file mode 100644 (file)
index 2a04d1d..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import Loading from '../common/Loading';
-import i18n from '../../i18n';
-
-const ScoreChart = React.lazy(() => import('./ScoreChart'));
-
-const ScoreChartDialog = ({
-       onHide,
-       show,
-       tournament,
-}) =>
-<Modal className="score-chart-dialog" dialogClassName="modal-90w" onHide={onHide} show={show}>
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t('tournaments.scoreChart')}
-               </Modal.Title>
-       </Modal.Header>
-       <Modal.Body style={{ height: '80vh' }}>
-               <React.Suspense fallback={<Loading />}>
-                       <ScoreChart tournament={tournament} />
-               </React.Suspense>
-       </Modal.Body>
-       <Modal.Footer>
-               <Button onClick={onHide} variant="secondary">
-                       {i18n.t('button.close')}
-               </Button>
-       </Modal.Footer>
-</Modal>;
-
-ScoreChartDialog.propTypes = {
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(ScoreChartDialog);
diff --git a/resources/js/components/tournament/ScoreChartDialog.jsx b/resources/js/components/tournament/ScoreChartDialog.jsx
new file mode 100644 (file)
index 0000000..2a04d1d
--- /dev/null
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Loading from '../common/Loading';
+import i18n from '../../i18n';
+
+const ScoreChart = React.lazy(() => import('./ScoreChart'));
+
+const ScoreChartDialog = ({
+       onHide,
+       show,
+       tournament,
+}) =>
+<Modal className="score-chart-dialog" dialogClassName="modal-90w" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('tournaments.scoreChart')}
+               </Modal.Title>
+       </Modal.Header>
+       <Modal.Body style={{ height: '80vh' }}>
+               <React.Suspense fallback={<Loading />}>
+                       <ScoreChart tournament={tournament} />
+               </React.Suspense>
+       </Modal.Body>
+       <Modal.Footer>
+               <Button onClick={onHide} variant="secondary">
+                       {i18n.t('button.close')}
+               </Button>
+       </Modal.Footer>
+</Modal>;
+
+ScoreChartDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(ScoreChartDialog);
diff --git a/resources/js/components/tournament/Scoreboard.js b/resources/js/components/tournament/Scoreboard.js
deleted file mode 100644 (file)
index 27bb087..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Table } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from '../common/Icon';
-import Box from '../users/Box';
-import { comparePlacement } from '../../helpers/Participant';
-import { getRunners } from '../../helpers/Tournament';
-import { useUser } from '../../hooks/user';
-
-const getRowClassName = (tournament, participant, user) => {
-       const classNames = ['score'];
-       if (participant && user && participant.user_id == user.id) {
-               classNames.push('is-self');
-       }
-       return classNames.join(' ');
-};
-
-const getPlacementDisplay = participant => {
-       if (participant.placement === 1) {
-               return <Icon.FIRST_PLACE className="text-gold" size="lg" />;
-       }
-       if (participant.placement === 2) {
-               return <Icon.SECOND_PLACE className="text-silver" size="lg" />;
-       }
-       if (participant.placement === 3) {
-               return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
-       }
-       return participant.placement;
-};
-
-const twitchReg = /^https?:\/\/(www\.)?twitch\.tv/;
-const youtubeReg = /^https?:\/\/(www\.)?youtu(\.be|be\.)/;
-
-const getStreamVariant = participant => {
-       if (!participant || !participant.user || !participant.user.stream_link) {
-               return 'outline-secondary';
-       }
-       if (twitchReg.test(participant.user.stream_link)) {
-               return 'outline-twitch';
-       }
-       if (youtubeReg.test(participant.user.stream_link)) {
-               return 'outline-youtube';
-       }
-       return 'outline-secondary';
-};
-
-const getStreamIcon = participant => {
-       const variant = getStreamVariant(participant);
-       if (variant === 'outline-twitch') {
-               return <Icon.TWITCH title="" />;
-       }
-       if (variant === 'outline-youtube') {
-               return <Icon.YOUTUBE title="" />;
-       }
-       return <Icon.VIDEO title="" />;
-};
-
-const Scoreboard = ({ tournament }) => {
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       return <Table striped className="scoreboard align-middle">
-               <thead>
-                       <tr>
-                               <th className="text-center">{t('participants.placementShort')}</th>
-                               <th>{t('participants.participant')}</th>
-                               <th className="text-end">{t('participants.scoreShort')}</th>
-                       </tr>
-               </thead>
-               <tbody>
-               {getRunners(tournament).sort(comparePlacement).map(participant =>
-                       <tr className={getRowClassName(tournament, participant, user)} key={participant.id}>
-                               <td className="text-center">
-                                       {getPlacementDisplay(participant)}
-                               </td>
-                               <td>
-                                       <div className="d-flex align-items-center justify-content-between">
-                                               <Box user={participant.user} />
-                                               {participant.user.stream_link ?
-                                                       <Button
-                                                               href={participant.user.stream_link}
-                                                               size="sm"
-                                                               target="_blank"
-                                                               title={t('users.stream')}
-                                                               variant={getStreamVariant(participant)}
-                                                       >
-                                                               {getStreamIcon(participant)}
-                                                       </Button>
-                                               : null}
-                                       </div>
-                               </td>
-                               <td className="text-end">{participant.score}</td>
-                       </tr>
-               )}
-               </tbody>
-       </Table>;
-};
-
-Scoreboard.propTypes = {
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default Scoreboard;
diff --git a/resources/js/components/tournament/Scoreboard.jsx b/resources/js/components/tournament/Scoreboard.jsx
new file mode 100644 (file)
index 0000000..27bb087
--- /dev/null
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Table } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import Box from '../users/Box';
+import { comparePlacement } from '../../helpers/Participant';
+import { getRunners } from '../../helpers/Tournament';
+import { useUser } from '../../hooks/user';
+
+const getRowClassName = (tournament, participant, user) => {
+       const classNames = ['score'];
+       if (participant && user && participant.user_id == user.id) {
+               classNames.push('is-self');
+       }
+       return classNames.join(' ');
+};
+
+const getPlacementDisplay = participant => {
+       if (participant.placement === 1) {
+               return <Icon.FIRST_PLACE className="text-gold" size="lg" />;
+       }
+       if (participant.placement === 2) {
+               return <Icon.SECOND_PLACE className="text-silver" size="lg" />;
+       }
+       if (participant.placement === 3) {
+               return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
+       }
+       return participant.placement;
+};
+
+const twitchReg = /^https?:\/\/(www\.)?twitch\.tv/;
+const youtubeReg = /^https?:\/\/(www\.)?youtu(\.be|be\.)/;
+
+const getStreamVariant = participant => {
+       if (!participant || !participant.user || !participant.user.stream_link) {
+               return 'outline-secondary';
+       }
+       if (twitchReg.test(participant.user.stream_link)) {
+               return 'outline-twitch';
+       }
+       if (youtubeReg.test(participant.user.stream_link)) {
+               return 'outline-youtube';
+       }
+       return 'outline-secondary';
+};
+
+const getStreamIcon = participant => {
+       const variant = getStreamVariant(participant);
+       if (variant === 'outline-twitch') {
+               return <Icon.TWITCH title="" />;
+       }
+       if (variant === 'outline-youtube') {
+               return <Icon.YOUTUBE title="" />;
+       }
+       return <Icon.VIDEO title="" />;
+};
+
+const Scoreboard = ({ tournament }) => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       return <Table striped className="scoreboard align-middle">
+               <thead>
+                       <tr>
+                               <th className="text-center">{t('participants.placementShort')}</th>
+                               <th>{t('participants.participant')}</th>
+                               <th className="text-end">{t('participants.scoreShort')}</th>
+                       </tr>
+               </thead>
+               <tbody>
+               {getRunners(tournament).sort(comparePlacement).map(participant =>
+                       <tr className={getRowClassName(tournament, participant, user)} key={participant.id}>
+                               <td className="text-center">
+                                       {getPlacementDisplay(participant)}
+                               </td>
+                               <td>
+                                       <div className="d-flex align-items-center justify-content-between">
+                                               <Box user={participant.user} />
+                                               {participant.user.stream_link ?
+                                                       <Button
+                                                               href={participant.user.stream_link}
+                                                               size="sm"
+                                                               target="_blank"
+                                                               title={t('users.stream')}
+                                                               variant={getStreamVariant(participant)}
+                                                       >
+                                                               {getStreamIcon(participant)}
+                                                       </Button>
+                                               : null}
+                                       </div>
+                               </td>
+                               <td className="text-end">{participant.score}</td>
+                       </tr>
+               )}
+               </tbody>
+       </Table>;
+};
+
+Scoreboard.propTypes = {
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default Scoreboard;
diff --git a/resources/js/components/tournament/SettingsButton.js b/resources/js/components/tournament/SettingsButton.js
deleted file mode 100644 (file)
index 2ff1abd..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import SettingsDialog from './SettingsDialog';
-import Icon from '../common/Icon';
-import i18n from '../../i18n';
-
-const SettingsButton = ({ tournament }) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       return <>
-               <Button
-                       onClick={() => setShowDialog(true)}
-                       title={i18n.t('button.settings')}
-                       variant="outline-secondary"
-               >
-                       <Icon.SETTINGS title="" />
-               </Button>
-               <SettingsDialog
-                       onHide={() => setShowDialog(false)}
-                       tournament={tournament}
-                       show={showDialog}
-               />
-       </>;
-};
-
-SettingsButton.propTypes = {
-       tournament: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(SettingsButton);
diff --git a/resources/js/components/tournament/SettingsButton.jsx b/resources/js/components/tournament/SettingsButton.jsx
new file mode 100644 (file)
index 0000000..2ff1abd
--- /dev/null
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import SettingsDialog from './SettingsDialog';
+import Icon from '../common/Icon';
+import i18n from '../../i18n';
+
+const SettingsButton = ({ tournament }) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       return <>
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       title={i18n.t('button.settings')}
+                       variant="outline-secondary"
+               >
+                       <Icon.SETTINGS title="" />
+               </Button>
+               <SettingsDialog
+                       onHide={() => setShowDialog(false)}
+                       tournament={tournament}
+                       show={showDialog}
+               />
+       </>;
+};
+
+SettingsButton.propTypes = {
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(SettingsButton);
diff --git a/resources/js/components/tournament/SettingsDialog.js b/resources/js/components/tournament/SettingsDialog.js
deleted file mode 100644 (file)
index a40a0a1..0000000
+++ /dev/null
@@ -1,186 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import DiscordForm from './DiscordForm';
-import DiscordSelect from '../common/DiscordSelect';
-import Icon from '../common/Icon';
-import ToggleSwitch from '../common/ToggleSwitch';
-import Tournament from '../../helpers/Tournament';
-import i18n from '../../i18n';
-
-const open = async tournament => {
-       try {
-               await axios.post(`/api/tournaments/${tournament.id}/open`);
-               toastr.success(i18n.t('tournaments.openSuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('tournaments.openError'));
-       }
-};
-
-const close = async tournament => {
-       try {
-               await axios.post(`/api/tournaments/${tournament.id}/close`);
-               toastr.success(i18n.t('tournaments.closeSuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('tournaments.closeError'));
-       }
-};
-
-const lock = async tournament => {
-       try {
-               await axios.post(`/api/tournaments/${tournament.id}/lock`);
-               toastr.success(i18n.t('tournaments.lockSuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('tournaments.lockError'));
-       }
-};
-
-const unlock = async tournament => {
-       try {
-               await axios.post(`/api/tournaments/${tournament.id}/unlock`);
-               toastr.success(i18n.t('tournaments.unlockSuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('tournaments.unlockError'));
-       }
-};
-
-const setDiscord = async (tournament, guild_id) => {
-       try {
-               await axios.post(`/api/tournaments/${tournament.id}/discord`, { guild_id });
-               toastr.success(i18n.t('tournaments.discordSuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('tournaments.discordError'));
-       }
-};
-
-const settings = async (tournament, params) => {
-       try {
-               await axios.post(`/api/tournaments/${tournament.id}/settings`, params);
-               toastr.success(i18n.t('tournaments.settingsSuccess'));
-       } catch (e) {
-               toastr.error(i18n.t('tournaments.settingsError'));
-       }
-};
-
-const inviteUrl = 'https://discordapp.com/oauth2/authorize?client_id=951113702839549982&scope=bot';
-
-const SettingsDialog = ({
-       onHide,
-       show,
-       tournament,
-}) =>
-<Modal
-       className="settings-dialog"
-       onHide={onHide}
-       show={show}
-       size={tournament.discord ? 'lg' : 'md'}
->
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t('tournaments.settings')}
-               </Modal.Title>
-       </Modal.Header>
-       <Modal.Body>
-               <Row>
-                       <Col sm={tournament.discord ? 6 : 12}>
-                               {Tournament.hasSignup(tournament) ?
-                                       <div className="d-flex align-items-center justify-content-between mb-3">
-                                               <span>{i18n.t('tournaments.open')}</span>
-                                               <ToggleSwitch
-                                                       onChange={({ target: { value } }) => value
-                                                               ? open(tournament) : close(tournament)}
-                                                       value={tournament.accept_applications}
-                                               />
-                                       </div>
-                               : null}
-                               <div className="d-flex align-items-center justify-content-between mb-3">
-                                       <span>{i18n.t('tournaments.locked')}</span>
-                                       <ToggleSwitch
-                                               onChange={({ target: { value } }) => value
-                                                       ? lock(tournament) : unlock(tournament)}
-                                               value={tournament.locked}
-                                       />
-                               </div>
-                               <div className="d-flex align-items-center justify-content-between mb-3">
-                                       <span>{i18n.t('tournaments.showNumbers')}</span>
-                                       <ToggleSwitch
-                                               onChange={({ target: { value } }) =>
-                                                       settings(tournament, { show_numbers: value })}
-                                               value={tournament.show_numbers}
-                                       />
-                               </div>
-                               <div className="d-flex align-items-center justify-content-between mb-3">
-                                       <span title={i18n.t('tournaments.resultRevealDescription')}>
-                                               {i18n.t('tournaments.resultReveal')}
-                                       </span>
-                                       <Form.Select
-                                               onChange={({ target: { value } }) =>
-                                                       settings(tournament, { result_reveal: value })}
-                                               style={{ width: '50%' }}
-                                               value={tournament.result_reveal}
-                                       >
-                                               {['never', 'finishers', 'participants', 'always'].map((key) =>
-                                                       <option
-                                                               key={key}
-                                                               title={i18n.t(`tournaments.resultRevealOptionDescription.${key}`)}
-                                                               value={key}
-                                                       >
-                                                               {i18n.t(`tournaments.resultRevealOption.${key}`)}
-                                                       </option>
-                                               )}
-                                       </Form.Select>
-                               </div>
-                               <div className="d-flex align-items-center justify-content-between">
-                                       <div>
-                                               <p>{i18n.t('tournaments.discord')}</p>
-                                               {!tournament.discord ?
-                                                       <div>
-                                                               <Button
-                                                                       href={inviteUrl}
-                                                                       target="_blank"
-                                                                       variant="discord"
-                                                               >
-                                                                       <Icon.DISCORD />
-                                                                       {' '}
-                                                                       {i18n.t('tournaments.inviteBot')}
-                                                               </Button>
-                                                       </div>
-                                               : null}
-                                       </div>
-                                       <DiscordSelect
-                                               onChange={({ target: { value } }) => setDiscord(tournament, value)}
-                                               value={tournament.discord}
-                                       />
-                               </div>
-                       </Col>
-                       {tournament.discord ?
-                               <Col sm={6}>
-                                       <DiscordForm tournament={tournament} />
-                               </Col>
-                       : null}
-               </Row>
-       </Modal.Body>
-       <Modal.Footer>
-               <Button onClick={onHide} variant="secondary">
-                       {i18n.t('button.close')}
-               </Button>
-       </Modal.Footer>
-</Modal>;
-
-SettingsDialog.propTypes = {
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-       tournament: PropTypes.shape({
-               accept_applications: PropTypes.bool,
-               discord: PropTypes.string,
-               locked: PropTypes.bool,
-               result_reveal: PropTypes.string,
-               show_numbers: PropTypes.bool,
-       }),
-};
-
-export default withTranslation()(SettingsDialog);
diff --git a/resources/js/components/tournament/SettingsDialog.jsx b/resources/js/components/tournament/SettingsDialog.jsx
new file mode 100644 (file)
index 0000000..a40a0a1
--- /dev/null
@@ -0,0 +1,186 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import DiscordForm from './DiscordForm';
+import DiscordSelect from '../common/DiscordSelect';
+import Icon from '../common/Icon';
+import ToggleSwitch from '../common/ToggleSwitch';
+import Tournament from '../../helpers/Tournament';
+import i18n from '../../i18n';
+
+const open = async tournament => {
+       try {
+               await axios.post(`/api/tournaments/${tournament.id}/open`);
+               toastr.success(i18n.t('tournaments.openSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('tournaments.openError'));
+       }
+};
+
+const close = async tournament => {
+       try {
+               await axios.post(`/api/tournaments/${tournament.id}/close`);
+               toastr.success(i18n.t('tournaments.closeSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('tournaments.closeError'));
+       }
+};
+
+const lock = async tournament => {
+       try {
+               await axios.post(`/api/tournaments/${tournament.id}/lock`);
+               toastr.success(i18n.t('tournaments.lockSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('tournaments.lockError'));
+       }
+};
+
+const unlock = async tournament => {
+       try {
+               await axios.post(`/api/tournaments/${tournament.id}/unlock`);
+               toastr.success(i18n.t('tournaments.unlockSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('tournaments.unlockError'));
+       }
+};
+
+const setDiscord = async (tournament, guild_id) => {
+       try {
+               await axios.post(`/api/tournaments/${tournament.id}/discord`, { guild_id });
+               toastr.success(i18n.t('tournaments.discordSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('tournaments.discordError'));
+       }
+};
+
+const settings = async (tournament, params) => {
+       try {
+               await axios.post(`/api/tournaments/${tournament.id}/settings`, params);
+               toastr.success(i18n.t('tournaments.settingsSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('tournaments.settingsError'));
+       }
+};
+
+const inviteUrl = 'https://discordapp.com/oauth2/authorize?client_id=951113702839549982&scope=bot';
+
+const SettingsDialog = ({
+       onHide,
+       show,
+       tournament,
+}) =>
+<Modal
+       className="settings-dialog"
+       onHide={onHide}
+       show={show}
+       size={tournament.discord ? 'lg' : 'md'}
+>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('tournaments.settings')}
+               </Modal.Title>
+       </Modal.Header>
+       <Modal.Body>
+               <Row>
+                       <Col sm={tournament.discord ? 6 : 12}>
+                               {Tournament.hasSignup(tournament) ?
+                                       <div className="d-flex align-items-center justify-content-between mb-3">
+                                               <span>{i18n.t('tournaments.open')}</span>
+                                               <ToggleSwitch
+                                                       onChange={({ target: { value } }) => value
+                                                               ? open(tournament) : close(tournament)}
+                                                       value={tournament.accept_applications}
+                                               />
+                                       </div>
+                               : null}
+                               <div className="d-flex align-items-center justify-content-between mb-3">
+                                       <span>{i18n.t('tournaments.locked')}</span>
+                                       <ToggleSwitch
+                                               onChange={({ target: { value } }) => value
+                                                       ? lock(tournament) : unlock(tournament)}
+                                               value={tournament.locked}
+                                       />
+                               </div>
+                               <div className="d-flex align-items-center justify-content-between mb-3">
+                                       <span>{i18n.t('tournaments.showNumbers')}</span>
+                                       <ToggleSwitch
+                                               onChange={({ target: { value } }) =>
+                                                       settings(tournament, { show_numbers: value })}
+                                               value={tournament.show_numbers}
+                                       />
+                               </div>
+                               <div className="d-flex align-items-center justify-content-between mb-3">
+                                       <span title={i18n.t('tournaments.resultRevealDescription')}>
+                                               {i18n.t('tournaments.resultReveal')}
+                                       </span>
+                                       <Form.Select
+                                               onChange={({ target: { value } }) =>
+                                                       settings(tournament, { result_reveal: value })}
+                                               style={{ width: '50%' }}
+                                               value={tournament.result_reveal}
+                                       >
+                                               {['never', 'finishers', 'participants', 'always'].map((key) =>
+                                                       <option
+                                                               key={key}
+                                                               title={i18n.t(`tournaments.resultRevealOptionDescription.${key}`)}
+                                                               value={key}
+                                                       >
+                                                               {i18n.t(`tournaments.resultRevealOption.${key}`)}
+                                                       </option>
+                                               )}
+                                       </Form.Select>
+                               </div>
+                               <div className="d-flex align-items-center justify-content-between">
+                                       <div>
+                                               <p>{i18n.t('tournaments.discord')}</p>
+                                               {!tournament.discord ?
+                                                       <div>
+                                                               <Button
+                                                                       href={inviteUrl}
+                                                                       target="_blank"
+                                                                       variant="discord"
+                                                               >
+                                                                       <Icon.DISCORD />
+                                                                       {' '}
+                                                                       {i18n.t('tournaments.inviteBot')}
+                                                               </Button>
+                                                       </div>
+                                               : null}
+                                       </div>
+                                       <DiscordSelect
+                                               onChange={({ target: { value } }) => setDiscord(tournament, value)}
+                                               value={tournament.discord}
+                                       />
+                               </div>
+                       </Col>
+                       {tournament.discord ?
+                               <Col sm={6}>
+                                       <DiscordForm tournament={tournament} />
+                               </Col>
+                       : null}
+               </Row>
+       </Modal.Body>
+       <Modal.Footer>
+               <Button onClick={onHide} variant="secondary">
+                       {i18n.t('button.close')}
+               </Button>
+       </Modal.Footer>
+</Modal>;
+
+SettingsDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+               accept_applications: PropTypes.bool,
+               discord: PropTypes.string,
+               locked: PropTypes.bool,
+               result_reveal: PropTypes.string,
+               show_numbers: PropTypes.bool,
+       }),
+};
+
+export default withTranslation()(SettingsDialog);
diff --git a/resources/js/components/tracker/AutoTracking.js b/resources/js/components/tracker/AutoTracking.js
deleted file mode 100644 (file)
index dfa1972..0000000
+++ /dev/null
@@ -1,153 +0,0 @@
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from '../common/Icon';
-import ToggleSwitch from '../common/ToggleSwitch';
-import {
-       IN_GAME_MODES,
-       RAM_ADDR,
-       SRAM_ADDR,
-       WRAM_ADDR,
-       buildPrizeMap,
-} from '../../helpers/alttp-ram';
-import { computeState } from '../../helpers/tracker';
-import { useSNES } from '../../hooks/snes';
-import { useTracker } from '../../hooks/tracker';
-
-const AutoTracking = () => {
-       const [enabled, setEnabled] = React.useState(false);
-       const [prizeMap, setPrizeMap] = React.useState(buildPrizeMap());
-
-       const {
-               disable: disableSNES,
-               enable: enableSNES,
-               openSettings,
-               sock,
-               status,
-       } = useSNES();
-       const { config, setAutoState } = useTracker();
-       const { t } = useTranslation();
-
-       const enable = React.useCallback(() => {
-               enableSNES();
-               setEnabled(true);
-       }, []);
-
-       const disable = React.useCallback(() => {
-               disableSNES();
-               setEnabled(false);
-       }, []);
-
-       React.useEffect(() => {
-               const savedSettings = localStorage.getItem('tracker.settings');
-               if (savedSettings) {
-                       const settings = JSON.parse(savedSettings);
-                       if (settings.autoTrack) {
-                               enable();
-                       }
-               }
-       }, []);
-
-       const saveSettings = React.useCallback((newSettings) => {
-               const savedSettings = localStorage.getItem('tracker.settings');
-               const settings = savedSettings
-                       ? { ...JSON.parse(savedSettings), ...newSettings }
-                       : newSettings;
-               localStorage.setItem('tracker.settings', JSON.stringify(settings));
-       }, []);
-
-       const toggle = React.useCallback(() => {
-               if (enabled) {
-                       disable();
-                       saveSettings({ autoTrack: false });
-               } else {
-                       enable();
-                       saveSettings({ autoTrack: true });
-               }
-       }, [enabled]);
-
-       // poll game and push state
-       React.useEffect(() => {
-               if (!enabled || status.error || !status.connected || !status.device) return;
-               const updateState = () => {
-                       const saveStart = WRAM_ADDR.SAVE_DATA;
-                       const saveSize = SRAM_ADDR.INV_END;
-                       sock.current.readWRAM(saveStart, saveSize, (data) => {
-                               const computed = computeState(config, data, prizeMap);
-                               setAutoState(computed);
-                       });
-               };
-               const fetchPrizes = () => {
-                       sock.current.readBytes(RAM_ADDR.PRIZE_MAP, 13, (prizes) => {
-                               sock.current.readBytes(RAM_ADDR.CRYSTAL_MAP, 13, (crystals) => {
-                                       setPrizeMap(m => {
-                                               const newMap = buildPrizeMap(prizes, crystals);
-                                               return JSON.stringify(m) === JSON.stringify(newMap) ? m : newMap;
-                                       });
-                               });
-                       });
-               };
-               const checkInGame = () => {
-                       sock.current.readWRAM(WRAM_ADDR.GAME_MODE, 1, (data) => {
-                               if (IN_GAME_MODES.includes(data[0])) {
-                                       fetchPrizes();
-                                       updateState();
-                               }
-                       });
-               };
-               const timer = setInterval(checkInGame, 1000);
-               return () => {
-                       clearInterval(timer);
-               };
-       }, [enabled && !status.error && status.connected && status.device, config, prizeMap, sock]);
-
-       const statusMsg = React.useMemo(() => {
-               if (!enabled) {
-                       return 'disabled';
-               }
-               if (status.error) {
-                       return 'error';
-               }
-               if (!status.connected) {
-                       return 'disconnected';
-               }
-               if (!status.device) {
-                       return 'no-device';
-               }
-               return 'tracking';
-       }, [enabled, status]);
-
-       return <div className="auto-tracking">
-               {['disconnected', 'error', 'no-device'].includes(statusMsg) ?
-                       <Icon.WARNING
-                               className="me-2 text-warning"
-                               size="lg"
-                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
-                       />
-               : null}
-               {['not-applicable', 'not-in-game'].includes(statusMsg) ?
-                       <Icon.INFO
-                               className="me-2 text-info"
-                               size="lg"
-                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
-                       />
-               : null}
-               <Button
-                       className="me-2"
-                       onClick={openSettings}
-                       size="sm"
-                       title={t('snes.settings')}
-                       variant="outline-secondary"
-               >
-                       <Icon.SETTINGS title="" />
-               </Button>
-               <ToggleSwitch
-                       onChange={toggle}
-                       title={t('autoTracking.heading')}
-                       value={enabled}
-               />
-       </div>;
-};
-
-export default AutoTracking;
diff --git a/resources/js/components/tracker/AutoTracking.jsx b/resources/js/components/tracker/AutoTracking.jsx
new file mode 100644 (file)
index 0000000..dfa1972
--- /dev/null
@@ -0,0 +1,153 @@
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import ToggleSwitch from '../common/ToggleSwitch';
+import {
+       IN_GAME_MODES,
+       RAM_ADDR,
+       SRAM_ADDR,
+       WRAM_ADDR,
+       buildPrizeMap,
+} from '../../helpers/alttp-ram';
+import { computeState } from '../../helpers/tracker';
+import { useSNES } from '../../hooks/snes';
+import { useTracker } from '../../hooks/tracker';
+
+const AutoTracking = () => {
+       const [enabled, setEnabled] = React.useState(false);
+       const [prizeMap, setPrizeMap] = React.useState(buildPrizeMap());
+
+       const {
+               disable: disableSNES,
+               enable: enableSNES,
+               openSettings,
+               sock,
+               status,
+       } = useSNES();
+       const { config, setAutoState } = useTracker();
+       const { t } = useTranslation();
+
+       const enable = React.useCallback(() => {
+               enableSNES();
+               setEnabled(true);
+       }, []);
+
+       const disable = React.useCallback(() => {
+               disableSNES();
+               setEnabled(false);
+       }, []);
+
+       React.useEffect(() => {
+               const savedSettings = localStorage.getItem('tracker.settings');
+               if (savedSettings) {
+                       const settings = JSON.parse(savedSettings);
+                       if (settings.autoTrack) {
+                               enable();
+                       }
+               }
+       }, []);
+
+       const saveSettings = React.useCallback((newSettings) => {
+               const savedSettings = localStorage.getItem('tracker.settings');
+               const settings = savedSettings
+                       ? { ...JSON.parse(savedSettings), ...newSettings }
+                       : newSettings;
+               localStorage.setItem('tracker.settings', JSON.stringify(settings));
+       }, []);
+
+       const toggle = React.useCallback(() => {
+               if (enabled) {
+                       disable();
+                       saveSettings({ autoTrack: false });
+               } else {
+                       enable();
+                       saveSettings({ autoTrack: true });
+               }
+       }, [enabled]);
+
+       // poll game and push state
+       React.useEffect(() => {
+               if (!enabled || status.error || !status.connected || !status.device) return;
+               const updateState = () => {
+                       const saveStart = WRAM_ADDR.SAVE_DATA;
+                       const saveSize = SRAM_ADDR.INV_END;
+                       sock.current.readWRAM(saveStart, saveSize, (data) => {
+                               const computed = computeState(config, data, prizeMap);
+                               setAutoState(computed);
+                       });
+               };
+               const fetchPrizes = () => {
+                       sock.current.readBytes(RAM_ADDR.PRIZE_MAP, 13, (prizes) => {
+                               sock.current.readBytes(RAM_ADDR.CRYSTAL_MAP, 13, (crystals) => {
+                                       setPrizeMap(m => {
+                                               const newMap = buildPrizeMap(prizes, crystals);
+                                               return JSON.stringify(m) === JSON.stringify(newMap) ? m : newMap;
+                                       });
+                               });
+                       });
+               };
+               const checkInGame = () => {
+                       sock.current.readWRAM(WRAM_ADDR.GAME_MODE, 1, (data) => {
+                               if (IN_GAME_MODES.includes(data[0])) {
+                                       fetchPrizes();
+                                       updateState();
+                               }
+                       });
+               };
+               const timer = setInterval(checkInGame, 1000);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [enabled && !status.error && status.connected && status.device, config, prizeMap, sock]);
+
+       const statusMsg = React.useMemo(() => {
+               if (!enabled) {
+                       return 'disabled';
+               }
+               if (status.error) {
+                       return 'error';
+               }
+               if (!status.connected) {
+                       return 'disconnected';
+               }
+               if (!status.device) {
+                       return 'no-device';
+               }
+               return 'tracking';
+       }, [enabled, status]);
+
+       return <div className="auto-tracking">
+               {['disconnected', 'error', 'no-device'].includes(statusMsg) ?
+                       <Icon.WARNING
+                               className="me-2 text-warning"
+                               size="lg"
+                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
+                       />
+               : null}
+               {['not-applicable', 'not-in-game'].includes(statusMsg) ?
+                       <Icon.INFO
+                               className="me-2 text-info"
+                               size="lg"
+                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
+                       />
+               : null}
+               <Button
+                       className="me-2"
+                       onClick={openSettings}
+                       size="sm"
+                       title={t('snes.settings')}
+                       variant="outline-secondary"
+               >
+                       <Icon.SETTINGS title="" />
+               </Button>
+               <ToggleSwitch
+                       onChange={toggle}
+                       title={t('autoTracking.heading')}
+                       value={enabled}
+               />
+       </div>;
+};
+
+export default AutoTracking;
diff --git a/resources/js/components/tracker/Canvas.js b/resources/js/components/tracker/Canvas.js
deleted file mode 100644 (file)
index 62dafe1..0000000
+++ /dev/null
@@ -1,173 +0,0 @@
-import { drag } from 'd3-drag';
-import { select } from 'd3-selection';
-import React from 'react';
-
-import Dungeons from './Dungeons';
-import Items from './Items';
-import Map from './Map';
-import ToggleIcon from './ToggleIcon';
-import ZeldaIcon from '../common/ZeldaIcon';
-import { shouldShowDungeonItem } from '../../helpers/tracker';
-import { useTracker } from '../../hooks/tracker';
-
-const LAYOUTS = {
-       defaultHorizontal: {
-               width: 100,
-               height: 60,
-               itemsTransform: 'translate(1 1) scale(22)',
-               dungeonColumns: 4,
-               dungeonsTransform: 'translate(1 39) scale(98)',
-               mapTransform: 'translate(24 0) scale(76)',
-       },
-       defaultVertical: {
-               width: 100,
-               height: 100,
-               itemsTransform: 'translate(10 1) scale(30)',
-               dungeonColumns: 2,
-               dungeonsTransform: 'translate(1 51) scale(48)',
-               mapTransform: 'translate(50 0) scale(50)',
-       },
-       manyDungeonItemsVertical: {
-               width: 80,
-               height: 100,
-               itemsTransform: 'translate(1 1) scale(27)',
-               dungeonColumns: 1,
-               dungeonsTransform: 'translate(1 48) scale(24)',
-               mapTransform: 'translate(30 0) scale(50)',
-       },
-};
-
-const Canvas = () => {
-       const [dragging, setDragging] = React.useState(null);
-       const { addPin, config, pins, removePin } = useTracker();
-
-       const layout = React.useMemo(() => {
-               if (config.mapLayout === 'vertical') {
-                       let count = 0;
-                       if (shouldShowDungeonItem(config, 'Map')) {
-                               ++count;
-                       }
-                       if (shouldShowDungeonItem(config, 'Compass')) {
-                               ++count;
-                       }
-                       if (shouldShowDungeonItem(config, 'Small')) {
-                               ++count;
-                       }
-                       if (shouldShowDungeonItem(config, 'Big')) {
-                               ++count;
-                       }
-                       const compact = config.compactKeysanity && count === 4;
-                       return !compact && count > 2
-                               ? LAYOUTS.manyDungeonItemsVertical : LAYOUTS.defaultVertical;
-               } else {
-                       return LAYOUTS.defaultHorizontal;
-               }
-       }, [config]);
-
-       React.useEffect(() => {
-               const canvas = select('.canvas');
-               const bbox = canvas.select('.background');
-               const start = { x: 0, y: 0 };
-               const onStart = function (e) {
-                       start.x = e.x;
-                       start.y = e.y;
-               };
-               const onDrag = function (e) {
-                       const bounds = bbox.node().getBoundingClientRect();
-                       const distance = Math.max(Math.abs(e.x - start.x), Math.abs(e.y - start.y));
-                       if (distance > 5) {
-                               setDragging({
-                                       icon: this.dataset['icon'],
-                                       x: (e.x - bounds.x) / bounds.width,
-                                       y: (e.y - bounds.y) / bounds.height,
-                               });
-                       } else {
-                               setDragging(null);
-                       }
-               };
-               const onEnd = function (e) {
-                       const bounds = bbox.node().getBoundingClientRect();
-                       setDragging(null);
-                       const distance = Math.max(Math.abs(e.x - start.x), Math.abs(e.y - start.y));
-                       if (distance > 5) {
-                               addPin({
-                                       icon: this.dataset['icon'],
-                                       x: (e.x - bounds.x) / bounds.width,
-                                       y: (e.y - bounds.y) / bounds.height,
-                               });
-                               if (this.classList.contains('map-pin')) {
-                                       let id = 0;
-                                       this.classList.forEach(name => {
-                                               if (name.startsWith('map-pin-')) {
-                                                       id = parseInt(name.substr(8), 10);
-                                               }
-                                       });
-                                       removePin({ id });
-                               }
-                       }
-               };
-               const selection = canvas.selectAll('.toggle-icon');
-               const draggable = drag()
-                       .container(bbox)
-                       .clickDistance(5)
-                       .on('start', onStart)
-                       .on('drag', onDrag)
-                       .on('end', onEnd);
-               selection.call(draggable);
-               return () => {
-                       selection.on('.drag', null);
-               };
-       }, [pins, removePin]);
-
-       return <svg
-               xmlns="http://www.w3.org/2000/svg"
-               className="canvas"
-               width={layout.width}
-               height={layout.height}
-               viewBox={`0 0 ${layout.width} ${layout.height}`}
-               onContextMenu={(e) => {
-                       e.preventDefault();
-                       e.stopPropagation();
-               }}
-       >
-               <rect
-                       className="background"
-                       fill="transparent"
-                       x="0" y="0"
-                       width={layout.width}
-                       height={layout.height}
-               />
-               <g className="items" transform={layout.itemsTransform}>
-                       <Items />
-               </g>
-               <g className="dungeons" transform={layout.dungeonsTransform}>
-                       <Dungeons columns={layout.dungeonColumns} />
-               </g>
-               <g className="tracker-map" transform={layout.mapTransform}>
-                       <Map />
-               </g>
-               <g className="pins">
-                       {pins.map(pin =>
-                               <ToggleIcon
-                                       key={pin.id}
-                                       className={`map-pin map-pin-${pin.id}`}
-                                       controller={ToggleIcon.pinController(pin, removePin)}
-                                       icons={[pin.icon]}
-                                       svg
-                                       transform={
-                                               `translate(${pin.x * layout.width} ${pin.y * layout.height}) scale(3)`
-                                       }
-                               />
-                       )}
-               </g>
-               {dragging ?
-                       <g transform={
-                               `translate(${dragging.x * layout.width} ${dragging.y * layout.height}) scale(4)`
-                       }>
-                               <ZeldaIcon name={dragging.icon} svg />
-                       </g>
-               : null}
-       </svg>;
-};
-
-export default Canvas;
diff --git a/resources/js/components/tracker/Canvas.jsx b/resources/js/components/tracker/Canvas.jsx
new file mode 100644 (file)
index 0000000..62dafe1
--- /dev/null
@@ -0,0 +1,173 @@
+import { drag } from 'd3-drag';
+import { select } from 'd3-selection';
+import React from 'react';
+
+import Dungeons from './Dungeons';
+import Items from './Items';
+import Map from './Map';
+import ToggleIcon from './ToggleIcon';
+import ZeldaIcon from '../common/ZeldaIcon';
+import { shouldShowDungeonItem } from '../../helpers/tracker';
+import { useTracker } from '../../hooks/tracker';
+
+const LAYOUTS = {
+       defaultHorizontal: {
+               width: 100,
+               height: 60,
+               itemsTransform: 'translate(1 1) scale(22)',
+               dungeonColumns: 4,
+               dungeonsTransform: 'translate(1 39) scale(98)',
+               mapTransform: 'translate(24 0) scale(76)',
+       },
+       defaultVertical: {
+               width: 100,
+               height: 100,
+               itemsTransform: 'translate(10 1) scale(30)',
+               dungeonColumns: 2,
+               dungeonsTransform: 'translate(1 51) scale(48)',
+               mapTransform: 'translate(50 0) scale(50)',
+       },
+       manyDungeonItemsVertical: {
+               width: 80,
+               height: 100,
+               itemsTransform: 'translate(1 1) scale(27)',
+               dungeonColumns: 1,
+               dungeonsTransform: 'translate(1 48) scale(24)',
+               mapTransform: 'translate(30 0) scale(50)',
+       },
+};
+
+const Canvas = () => {
+       const [dragging, setDragging] = React.useState(null);
+       const { addPin, config, pins, removePin } = useTracker();
+
+       const layout = React.useMemo(() => {
+               if (config.mapLayout === 'vertical') {
+                       let count = 0;
+                       if (shouldShowDungeonItem(config, 'Map')) {
+                               ++count;
+                       }
+                       if (shouldShowDungeonItem(config, 'Compass')) {
+                               ++count;
+                       }
+                       if (shouldShowDungeonItem(config, 'Small')) {
+                               ++count;
+                       }
+                       if (shouldShowDungeonItem(config, 'Big')) {
+                               ++count;
+                       }
+                       const compact = config.compactKeysanity && count === 4;
+                       return !compact && count > 2
+                               ? LAYOUTS.manyDungeonItemsVertical : LAYOUTS.defaultVertical;
+               } else {
+                       return LAYOUTS.defaultHorizontal;
+               }
+       }, [config]);
+
+       React.useEffect(() => {
+               const canvas = select('.canvas');
+               const bbox = canvas.select('.background');
+               const start = { x: 0, y: 0 };
+               const onStart = function (e) {
+                       start.x = e.x;
+                       start.y = e.y;
+               };
+               const onDrag = function (e) {
+                       const bounds = bbox.node().getBoundingClientRect();
+                       const distance = Math.max(Math.abs(e.x - start.x), Math.abs(e.y - start.y));
+                       if (distance > 5) {
+                               setDragging({
+                                       icon: this.dataset['icon'],
+                                       x: (e.x - bounds.x) / bounds.width,
+                                       y: (e.y - bounds.y) / bounds.height,
+                               });
+                       } else {
+                               setDragging(null);
+                       }
+               };
+               const onEnd = function (e) {
+                       const bounds = bbox.node().getBoundingClientRect();
+                       setDragging(null);
+                       const distance = Math.max(Math.abs(e.x - start.x), Math.abs(e.y - start.y));
+                       if (distance > 5) {
+                               addPin({
+                                       icon: this.dataset['icon'],
+                                       x: (e.x - bounds.x) / bounds.width,
+                                       y: (e.y - bounds.y) / bounds.height,
+                               });
+                               if (this.classList.contains('map-pin')) {
+                                       let id = 0;
+                                       this.classList.forEach(name => {
+                                               if (name.startsWith('map-pin-')) {
+                                                       id = parseInt(name.substr(8), 10);
+                                               }
+                                       });
+                                       removePin({ id });
+                               }
+                       }
+               };
+               const selection = canvas.selectAll('.toggle-icon');
+               const draggable = drag()
+                       .container(bbox)
+                       .clickDistance(5)
+                       .on('start', onStart)
+                       .on('drag', onDrag)
+                       .on('end', onEnd);
+               selection.call(draggable);
+               return () => {
+                       selection.on('.drag', null);
+               };
+       }, [pins, removePin]);
+
+       return <svg
+               xmlns="http://www.w3.org/2000/svg"
+               className="canvas"
+               width={layout.width}
+               height={layout.height}
+               viewBox={`0 0 ${layout.width} ${layout.height}`}
+               onContextMenu={(e) => {
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
+       >
+               <rect
+                       className="background"
+                       fill="transparent"
+                       x="0" y="0"
+                       width={layout.width}
+                       height={layout.height}
+               />
+               <g className="items" transform={layout.itemsTransform}>
+                       <Items />
+               </g>
+               <g className="dungeons" transform={layout.dungeonsTransform}>
+                       <Dungeons columns={layout.dungeonColumns} />
+               </g>
+               <g className="tracker-map" transform={layout.mapTransform}>
+                       <Map />
+               </g>
+               <g className="pins">
+                       {pins.map(pin =>
+                               <ToggleIcon
+                                       key={pin.id}
+                                       className={`map-pin map-pin-${pin.id}`}
+                                       controller={ToggleIcon.pinController(pin, removePin)}
+                                       icons={[pin.icon]}
+                                       svg
+                                       transform={
+                                               `translate(${pin.x * layout.width} ${pin.y * layout.height}) scale(3)`
+                                       }
+                               />
+                       )}
+               </g>
+               {dragging ?
+                       <g transform={
+                               `translate(${dragging.x * layout.width} ${dragging.y * layout.height}) scale(4)`
+                       }>
+                               <ZeldaIcon name={dragging.icon} svg />
+                       </g>
+               : null}
+       </svg>;
+};
+
+export default Canvas;
diff --git a/resources/js/components/tracker/ConfigDialog.js b/resources/js/components/tracker/ConfigDialog.js
deleted file mode 100644 (file)
index 9d6b5be..0000000
+++ /dev/null
@@ -1,347 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Col, Form, Modal, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import LargeCheck from '../common/LargeCheck';
-import { getConfigValue } from '../../helpers/tracker';
-import { useTracker } from '../../hooks/tracker';
-
-const ConfigDialog = ({
-       onHide,
-       show,
-}) => {
-       const { config, saveConfig } = useTracker();
-       const { t } = useTranslation();
-
-       const handleChange = React.useCallback(({ target: { name, value } }) => {
-               saveConfig({ [name]: value });
-       }, [saveConfig]);
-
-       return <Modal className="tracker-config-dialog" onHide={onHide} show={show} size="lg">
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t('tracker.config.title')}
-                       </Modal.Title>
-               </Modal.Header>
-               <Modal.Body>
-                       <Row>
-                               <Col sm={6}>
-                                       <h3>{t('tracker.config.logic')}</h3>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.worldState"
-                                       >
-                                               <Form.Label>{t('tracker.config.worldState')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="worldState"
-                                                       onChange={handleChange}
-                                                       value={getConfigValue(config, 'worldState', 'open')}
-                                               >
-                                                       {['open', 'inverted'].map(n =>
-                                                               <option key={n} value={n}>
-                                                                       {t(`tracker.config.worldStates.${n}`)}
-                                                               </option>
-                                                       )}
-                                               </Form.Select>
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.glitches"
-                                       >
-                                               <Form.Label>{t('tracker.config.glitches')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="glitches"
-                                                       onChange={handleChange}
-                                                       value={getConfigValue(config, 'glitches', 'none')}
-                                               >
-                                                       {['none', 'owg', 'hmg', 'mg', 'nl'].map(n =>
-                                                               <option key={n} value={n}>
-                                                                       {t(`tracker.config.glitchRules.${n}`)}
-                                                               </option>
-                                                       )}
-                                               </Form.Select>
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.bossShuffle"
-                                       >
-                                               <Form.Label>{t('tracker.config.bossShuffle')}</Form.Label>
-                                               <Form.Control
-                                                       as={LargeCheck}
-                                                       name="bossShuffle"
-                                                       onChange={handleChange}
-                                                       value={!!config.bossShuffle}
-                                               />
-                                       </Form.Group>
-                               </Col>
-                               <Col sm={6}>
-                                       <h3>{t('tracker.config.goal')}</h3>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.gtCrystals"
-                                       >
-                                               <Form.Label>{t('tracker.config.gtCrystals')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="gt-crystals"
-                                                       onChange={handleChange}
-                                                       value={getConfigValue(config, 'gt-crystals', 7)}
-                                               >
-                                                       {['?', 0, 1, 2, 3, 4, 5, 6, 7].map(n =>
-                                                               <option key={n} value={n}>
-                                                                       {n}
-                                                               </option>
-                                                       )}
-                                               </Form.Select>
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.ganonCrystals"
-                                       >
-                                               <Form.Label>{t('tracker.config.ganonCrystals')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="ganon-crystals"
-                                                       onChange={handleChange}
-                                                       value={getConfigValue(config, 'ganon-crystals', 7)}
-                                               >
-                                                       {['?', 0, 1, 2, 3, 4, 5, 6, 7].map(n =>
-                                                               <option key={n} value={n}>
-                                                                       {n}
-                                                               </option>
-                                                       )}
-                                               </Form.Select>
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.goal"
-                                       >
-                                               <Form.Label>{t('tracker.config.goal')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="goal"
-                                                       onChange={handleChange}
-                                                       value={getConfigValue(config, 'goal', 'ganon')}
-                                               >
-                                                       {['ganon', 'fast', 'ad', 'ped', 'trinity', 'thunt', 'ghunt'].map(n =>
-                                                               <option key={n} value={n}>
-                                                                       {t(`tracker.config.goals.${n}`)}
-                                                               </option>
-                                                       )}
-                                               </Form.Select>
-                                       </Form.Group>
-                               </Col>
-                       </Row>
-                       <Row className="mt-3">
-                               <Col sm={6}>
-                                       <h3>{t('tracker.config.wildItems')}</h3>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.wildMap"
-                                       >
-                                               <Form.Label>{t('tracker.config.wildMap')}</Form.Label>
-                                               <Form.Control
-                                                       as={LargeCheck}
-                                                       name="wildMap"
-                                                       onChange={handleChange}
-                                                       value={!!config.wildMap}
-                                               />
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.wildCompass"
-                                       >
-                                               <Form.Label>{t('tracker.config.wildCompass')}</Form.Label>
-                                               <Form.Control
-                                                       as={LargeCheck}
-                                                       name="wildCompass"
-                                                       onChange={handleChange}
-                                                       value={!!config.wildCompass}
-                                               />
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.wildSmall"
-                                       >
-                                               <Form.Label>{t('tracker.config.wildSmall')}</Form.Label>
-                                               <Form.Control
-                                                       as={LargeCheck}
-                                                       name="wildSmall"
-                                                       onChange={handleChange}
-                                                       value={!!config.wildSmall}
-                                               />
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.wildBig"
-                                       >
-                                               <Form.Label>{t('tracker.config.wildBig')}</Form.Label>
-                                               <Form.Control
-                                                       as={LargeCheck}
-                                                       name="wildBig"
-                                                       onChange={handleChange}
-                                                       value={!!config.wildBig}
-                                               />
-                                       </Form.Group>
-                               </Col>
-                               <Col sm={6}>
-                                       <h3>{t('tracker.config.showItems')}</h3>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.showMap"
-                                       >
-                                               <Form.Label>{t('tracker.config.showMap')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="showMap"
-                                                       onChange={handleChange}
-                                                       value={config.showMap || 'always'}
-                                               >
-                                                       <option value="never">
-                                                               {t('tracker.config.showItemOptions.never')}
-                                                       </option>
-                                                       <option value="situational">
-                                                               {t('tracker.config.showItemOptions.situational')}
-                                                       </option>
-                                                       <option value="always">
-                                                               {t('tracker.config.showItemOptions.always')}
-                                                       </option>
-                                               </Form.Select>
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.showCompass"
-                                       >
-                                               <Form.Label>{t('tracker.config.showCompass')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="showCompass"
-                                                       onChange={handleChange}
-                                                       value={config.showCompass || 'always'}
-                                               >
-                                                       <option value="never">
-                                                               {t('tracker.config.showItemOptions.never')}
-                                                       </option>
-                                                       <option value="situational">
-                                                               {t('tracker.config.showItemOptions.situational')}
-                                                       </option>
-                                                       <option value="always">
-                                                               {t('tracker.config.showItemOptions.always')}
-                                                       </option>
-                                               </Form.Select>
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.showSmall"
-                                       >
-                                               <Form.Label>{t('tracker.config.showSmall')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="showSmall"
-                                                       onChange={handleChange}
-                                                       value={config.showSmall || 'always'}
-                                               >
-                                                       <option value="never">
-                                                               {t('tracker.config.showItemOptions.never')}
-                                                       </option>
-                                                       <option value="situational">
-                                                               {t('tracker.config.showItemOptions.situational')}
-                                                       </option>
-                                                       <option value="always">
-                                                               {t('tracker.config.showItemOptions.always')}
-                                                       </option>
-                                               </Form.Select>
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.showBig"
-                                       >
-                                               <Form.Label>{t('tracker.config.showBig')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="showBig"
-                                                       onChange={handleChange}
-                                                       value={config.showBig || 'always'}
-                                               >
-                                                       <option value="never">
-                                                               {t('tracker.config.showItemOptions.never')}
-                                                       </option>
-                                                       <option value="situational">
-                                                               {t('tracker.config.showItemOptions.situational')}
-                                                       </option>
-                                                       <option value="always">
-                                                               {t('tracker.config.showItemOptions.always')}
-                                                       </option>
-                                               </Form.Select>
-                                       </Form.Group>
-                               </Col>
-                       </Row>
-                       <Row>
-                               <Col sm={6}>
-                                       <h3>{t('tracker.config.layout')}</h3>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.mapLayout"
-                                       >
-                                               <Form.Label>{t('tracker.config.mapLayout')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="mapLayout"
-                                                       onChange={handleChange}
-                                                       value={getConfigValue(config, 'mapLayout', 'horizontal')}
-                                               >
-                                                       {['horizontal', 'vertical'].map(n =>
-                                                               <option key={n} value={n}>
-                                                                       {t(`tracker.config.mapLayouts.${n}`)}
-                                                               </option>
-                                                       )}
-                                               </Form.Select>
-                                       </Form.Group>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.compactKeysanity"
-                                       >
-                                               <Form.Label>{t('tracker.config.compactKeysanity')}</Form.Label>
-                                               <Form.Control
-                                                       as={LargeCheck}
-                                                       name="compactKeysanity"
-                                                       onChange={handleChange}
-                                                       value={!!config.compactKeysanity}
-                                               />
-                                       </Form.Group>
-                               </Col>
-                               <Col sm={6}>
-                                       <h3>{t('tracker.config.calculation')}</h3>
-                                       <Form.Group
-                                               className="d-flex justify-content-between my-2"
-                                               controlId="tracker.checkCalculation"
-                                       >
-                                               <Form.Label>{t('tracker.config.checkCalculation')}</Form.Label>
-                                               <Form.Select
-                                                       className="w-auto"
-                                                       name="checkCalculation"
-                                                       onChange={handleChange}
-                                                       value={getConfigValue(config, 'checkCalculation', 'room-data')}
-                                               >
-                                                       {['inventory', 'room-data'].map(n =>
-                                                               <option key={n} value={n}>
-                                                                       {t(`tracker.config.checkCalculations.${n}`)}
-                                                               </option>
-                                                       )}
-                                               </Form.Select>
-                                       </Form.Group>
-                               </Col>
-                       </Row>
-               </Modal.Body>
-       </Modal>;
-};
-
-ConfigDialog.propTypes = {
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-};
-
-export default ConfigDialog;
diff --git a/resources/js/components/tracker/ConfigDialog.jsx b/resources/js/components/tracker/ConfigDialog.jsx
new file mode 100644 (file)
index 0000000..9d6b5be
--- /dev/null
@@ -0,0 +1,347 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Form, Modal, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import LargeCheck from '../common/LargeCheck';
+import { getConfigValue } from '../../helpers/tracker';
+import { useTracker } from '../../hooks/tracker';
+
+const ConfigDialog = ({
+       onHide,
+       show,
+}) => {
+       const { config, saveConfig } = useTracker();
+       const { t } = useTranslation();
+
+       const handleChange = React.useCallback(({ target: { name, value } }) => {
+               saveConfig({ [name]: value });
+       }, [saveConfig]);
+
+       return <Modal className="tracker-config-dialog" onHide={onHide} show={show} size="lg">
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('tracker.config.title')}
+                       </Modal.Title>
+               </Modal.Header>
+               <Modal.Body>
+                       <Row>
+                               <Col sm={6}>
+                                       <h3>{t('tracker.config.logic')}</h3>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.worldState"
+                                       >
+                                               <Form.Label>{t('tracker.config.worldState')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="worldState"
+                                                       onChange={handleChange}
+                                                       value={getConfigValue(config, 'worldState', 'open')}
+                                               >
+                                                       {['open', 'inverted'].map(n =>
+                                                               <option key={n} value={n}>
+                                                                       {t(`tracker.config.worldStates.${n}`)}
+                                                               </option>
+                                                       )}
+                                               </Form.Select>
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.glitches"
+                                       >
+                                               <Form.Label>{t('tracker.config.glitches')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="glitches"
+                                                       onChange={handleChange}
+                                                       value={getConfigValue(config, 'glitches', 'none')}
+                                               >
+                                                       {['none', 'owg', 'hmg', 'mg', 'nl'].map(n =>
+                                                               <option key={n} value={n}>
+                                                                       {t(`tracker.config.glitchRules.${n}`)}
+                                                               </option>
+                                                       )}
+                                               </Form.Select>
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.bossShuffle"
+                                       >
+                                               <Form.Label>{t('tracker.config.bossShuffle')}</Form.Label>
+                                               <Form.Control
+                                                       as={LargeCheck}
+                                                       name="bossShuffle"
+                                                       onChange={handleChange}
+                                                       value={!!config.bossShuffle}
+                                               />
+                                       </Form.Group>
+                               </Col>
+                               <Col sm={6}>
+                                       <h3>{t('tracker.config.goal')}</h3>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.gtCrystals"
+                                       >
+                                               <Form.Label>{t('tracker.config.gtCrystals')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="gt-crystals"
+                                                       onChange={handleChange}
+                                                       value={getConfigValue(config, 'gt-crystals', 7)}
+                                               >
+                                                       {['?', 0, 1, 2, 3, 4, 5, 6, 7].map(n =>
+                                                               <option key={n} value={n}>
+                                                                       {n}
+                                                               </option>
+                                                       )}
+                                               </Form.Select>
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.ganonCrystals"
+                                       >
+                                               <Form.Label>{t('tracker.config.ganonCrystals')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="ganon-crystals"
+                                                       onChange={handleChange}
+                                                       value={getConfigValue(config, 'ganon-crystals', 7)}
+                                               >
+                                                       {['?', 0, 1, 2, 3, 4, 5, 6, 7].map(n =>
+                                                               <option key={n} value={n}>
+                                                                       {n}
+                                                               </option>
+                                                       )}
+                                               </Form.Select>
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.goal"
+                                       >
+                                               <Form.Label>{t('tracker.config.goal')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="goal"
+                                                       onChange={handleChange}
+                                                       value={getConfigValue(config, 'goal', 'ganon')}
+                                               >
+                                                       {['ganon', 'fast', 'ad', 'ped', 'trinity', 'thunt', 'ghunt'].map(n =>
+                                                               <option key={n} value={n}>
+                                                                       {t(`tracker.config.goals.${n}`)}
+                                                               </option>
+                                                       )}
+                                               </Form.Select>
+                                       </Form.Group>
+                               </Col>
+                       </Row>
+                       <Row className="mt-3">
+                               <Col sm={6}>
+                                       <h3>{t('tracker.config.wildItems')}</h3>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.wildMap"
+                                       >
+                                               <Form.Label>{t('tracker.config.wildMap')}</Form.Label>
+                                               <Form.Control
+                                                       as={LargeCheck}
+                                                       name="wildMap"
+                                                       onChange={handleChange}
+                                                       value={!!config.wildMap}
+                                               />
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.wildCompass"
+                                       >
+                                               <Form.Label>{t('tracker.config.wildCompass')}</Form.Label>
+                                               <Form.Control
+                                                       as={LargeCheck}
+                                                       name="wildCompass"
+                                                       onChange={handleChange}
+                                                       value={!!config.wildCompass}
+                                               />
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.wildSmall"
+                                       >
+                                               <Form.Label>{t('tracker.config.wildSmall')}</Form.Label>
+                                               <Form.Control
+                                                       as={LargeCheck}
+                                                       name="wildSmall"
+                                                       onChange={handleChange}
+                                                       value={!!config.wildSmall}
+                                               />
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.wildBig"
+                                       >
+                                               <Form.Label>{t('tracker.config.wildBig')}</Form.Label>
+                                               <Form.Control
+                                                       as={LargeCheck}
+                                                       name="wildBig"
+                                                       onChange={handleChange}
+                                                       value={!!config.wildBig}
+                                               />
+                                       </Form.Group>
+                               </Col>
+                               <Col sm={6}>
+                                       <h3>{t('tracker.config.showItems')}</h3>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.showMap"
+                                       >
+                                               <Form.Label>{t('tracker.config.showMap')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="showMap"
+                                                       onChange={handleChange}
+                                                       value={config.showMap || 'always'}
+                                               >
+                                                       <option value="never">
+                                                               {t('tracker.config.showItemOptions.never')}
+                                                       </option>
+                                                       <option value="situational">
+                                                               {t('tracker.config.showItemOptions.situational')}
+                                                       </option>
+                                                       <option value="always">
+                                                               {t('tracker.config.showItemOptions.always')}
+                                                       </option>
+                                               </Form.Select>
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.showCompass"
+                                       >
+                                               <Form.Label>{t('tracker.config.showCompass')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="showCompass"
+                                                       onChange={handleChange}
+                                                       value={config.showCompass || 'always'}
+                                               >
+                                                       <option value="never">
+                                                               {t('tracker.config.showItemOptions.never')}
+                                                       </option>
+                                                       <option value="situational">
+                                                               {t('tracker.config.showItemOptions.situational')}
+                                                       </option>
+                                                       <option value="always">
+                                                               {t('tracker.config.showItemOptions.always')}
+                                                       </option>
+                                               </Form.Select>
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.showSmall"
+                                       >
+                                               <Form.Label>{t('tracker.config.showSmall')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="showSmall"
+                                                       onChange={handleChange}
+                                                       value={config.showSmall || 'always'}
+                                               >
+                                                       <option value="never">
+                                                               {t('tracker.config.showItemOptions.never')}
+                                                       </option>
+                                                       <option value="situational">
+                                                               {t('tracker.config.showItemOptions.situational')}
+                                                       </option>
+                                                       <option value="always">
+                                                               {t('tracker.config.showItemOptions.always')}
+                                                       </option>
+                                               </Form.Select>
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.showBig"
+                                       >
+                                               <Form.Label>{t('tracker.config.showBig')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="showBig"
+                                                       onChange={handleChange}
+                                                       value={config.showBig || 'always'}
+                                               >
+                                                       <option value="never">
+                                                               {t('tracker.config.showItemOptions.never')}
+                                                       </option>
+                                                       <option value="situational">
+                                                               {t('tracker.config.showItemOptions.situational')}
+                                                       </option>
+                                                       <option value="always">
+                                                               {t('tracker.config.showItemOptions.always')}
+                                                       </option>
+                                               </Form.Select>
+                                       </Form.Group>
+                               </Col>
+                       </Row>
+                       <Row>
+                               <Col sm={6}>
+                                       <h3>{t('tracker.config.layout')}</h3>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.mapLayout"
+                                       >
+                                               <Form.Label>{t('tracker.config.mapLayout')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="mapLayout"
+                                                       onChange={handleChange}
+                                                       value={getConfigValue(config, 'mapLayout', 'horizontal')}
+                                               >
+                                                       {['horizontal', 'vertical'].map(n =>
+                                                               <option key={n} value={n}>
+                                                                       {t(`tracker.config.mapLayouts.${n}`)}
+                                                               </option>
+                                                       )}
+                                               </Form.Select>
+                                       </Form.Group>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.compactKeysanity"
+                                       >
+                                               <Form.Label>{t('tracker.config.compactKeysanity')}</Form.Label>
+                                               <Form.Control
+                                                       as={LargeCheck}
+                                                       name="compactKeysanity"
+                                                       onChange={handleChange}
+                                                       value={!!config.compactKeysanity}
+                                               />
+                                       </Form.Group>
+                               </Col>
+                               <Col sm={6}>
+                                       <h3>{t('tracker.config.calculation')}</h3>
+                                       <Form.Group
+                                               className="d-flex justify-content-between my-2"
+                                               controlId="tracker.checkCalculation"
+                                       >
+                                               <Form.Label>{t('tracker.config.checkCalculation')}</Form.Label>
+                                               <Form.Select
+                                                       className="w-auto"
+                                                       name="checkCalculation"
+                                                       onChange={handleChange}
+                                                       value={getConfigValue(config, 'checkCalculation', 'room-data')}
+                                               >
+                                                       {['inventory', 'room-data'].map(n =>
+                                                               <option key={n} value={n}>
+                                                                       {t(`tracker.config.checkCalculations.${n}`)}
+                                                               </option>
+                                                       )}
+                                               </Form.Select>
+                                       </Form.Group>
+                               </Col>
+                       </Row>
+               </Modal.Body>
+       </Modal>;
+};
+
+ConfigDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+};
+
+export default ConfigDialog;
diff --git a/resources/js/components/tracker/CountDisplay.js b/resources/js/components/tracker/CountDisplay.js
deleted file mode 100644 (file)
index ba41b9f..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-const CountDisplay = ({ className, count, full }) => {
-       const classNames = ['count-display'];
-       if (className) {
-               classNames.push(className);
-       }
-       if (!count) {
-               classNames.push('is-zero');
-       }
-       if (full && count >= full) {
-               classNames.push('is-full');
-       }
-       return <text className={classNames.join(' ')}>
-               {count}
-       </text>;
-};
-
-CountDisplay.propTypes = {
-       className: PropTypes.string,
-       count: PropTypes.number,
-       full: PropTypes.number,
-};
-
-export default CountDisplay;
diff --git a/resources/js/components/tracker/CountDisplay.jsx b/resources/js/components/tracker/CountDisplay.jsx
new file mode 100644 (file)
index 0000000..ba41b9f
--- /dev/null
@@ -0,0 +1,26 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const CountDisplay = ({ className, count, full }) => {
+       const classNames = ['count-display'];
+       if (className) {
+               classNames.push(className);
+       }
+       if (!count) {
+               classNames.push('is-zero');
+       }
+       if (full && count >= full) {
+               classNames.push('is-full');
+       }
+       return <text className={classNames.join(' ')}>
+               {count}
+       </text>;
+};
+
+CountDisplay.propTypes = {
+       className: PropTypes.string,
+       count: PropTypes.number,
+       full: PropTypes.number,
+};
+
+export default CountDisplay;
diff --git a/resources/js/components/tracker/Dungeons.js b/resources/js/components/tracker/Dungeons.js
deleted file mode 100644 (file)
index 758935c..0000000
+++ /dev/null
@@ -1,204 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import CountDisplay from './CountDisplay';
-import ToggleIcon from './ToggleIcon';
-import {
-       BOSSES,
-       getDungeonAcquiredSKs,
-       getDungeonRemainingItems,
-       shouldCompactKeysanity,
-       shouldShowDungeonItem,
-} from '../../helpers/tracker';
-import { useTracker } from '../../hooks/tracker';
-
-const Dungeons = ({ columns = 4 }) => {
-       const { config, dungeons, state } = useTracker();
-
-       const layout = React.useMemo(() => {
-               const compact = shouldCompactKeysanity(config);
-               const mapX = 1;
-               const compassX = shouldShowDungeonItem(config, 'Map') ? mapX + 1 : mapX;
-               const smallX = shouldShowDungeonItem(config, 'Compass') ? compassX + 1 : compassX;
-               const bigX =  shouldShowDungeonItem(config, 'Small') ? smallX + 1 : smallX;
-               const countX = compact ? 2 : shouldShowDungeonItem(config, 'Big') ? bigX + 1 : bigX;
-               const bossX = countX + 1;
-               const prizeX = bossX + 1;
-               const dungeonWidth = Math.max(5, prizeX + 1);
-               const width = (columns * dungeonWidth) + Math.max(0, columns - 1);
-               const height = 4;
-
-               const transform = (col, row) =>
-                       `scale(${1 / width}) translate(${
-                               (col * dungeonWidth) + 0.5 + (col ? col - (columns > 3 ? 1 : 0) : 0)
-                       } ${row + 0.5})`;
-
-               const transforms = {
-                       tag: null,
-                       map: compact
-                               ? 'translate(0.75 -0.25) scale(0.45)' : `translate(${mapX} 0) scale(0.9)`,
-                       compass: compact
-                               ? 'translate(1.25 -0.25) scale(0.45)' : `translate(${compassX} 0) scale(0.9)`,
-                       small: compact
-                               ? 'translate(0.75 0.25) scale(0.45)' : `translate(${smallX} 0) scale(0.9)`,
-                       big: compact
-                               ? 'translate(1.25 0.25) scale(0.45)' : `translate(${bigX} 0) scale(0.9)`,
-                       checks: `translate(${countX} 0) scale(0.9)`,
-                       boss: `translate(${bossX} 0)`,
-                       prize: `translate(${prizeX} 0)`,
-                       hc: transform(0, 0),
-                       ct: transform(0, 1),
-                       gt: transform(0, 2),
-                       gtBoss1: `translate(${bossX - 2} 1)`,
-                       gtBoss2: `translate(${bossX - 1} 1)`,
-                       gtBoss3: `translate(${bossX} 1)`,
-               };
-
-               if (columns === 1) {
-                       transforms.ep = transform(0, 4);
-                       transforms.dp = transform(0, 5);
-                       transforms.th = transform(0, 6);
-                       transforms.pd = transform(0, 8);
-                       transforms.sp = transform(0, 9);
-                       transforms.sw = transform(0, 10);
-                       transforms.tt = transform(0, 11);
-                       transforms.ip = transform(0, 12);
-                       transforms.mm = transform(0, 13);
-                       transforms.tr = transform(0, 14);
-               } else if (columns === 2) {
-                       transforms.ep = transform(0, 4);
-                       transforms.dp = transform(0, 5);
-                       transforms.th = transform(0, 6);
-                       transforms.pd = transform(1, 0);
-                       transforms.sp = transform(1, 1);
-                       transforms.sw = transform(1, 2);
-                       transforms.tt = transform(1, 3);
-                       transforms.ip = transform(1, 4);
-                       transforms.mm = transform(1, 5);
-                       transforms.tr = transform(1, 6);
-               } else {
-                       transforms.ep = transform(1, 0);
-                       transforms.dp = transform(1, 1);
-                       transforms.th = transform(1, 2);
-                       transforms.pd = transform(2, 0);
-                       transforms.sp = transform(2, 1);
-                       transforms.sw = transform(2, 2);
-                       transforms.tt = transform(2, 3);
-                       transforms.ip = transform(3, 0);
-                       transforms.mm = transform(3, 1);
-                       transforms.tr = transform(3, 2);
-               }
-
-               return {
-                       width,
-                       height,
-                       transforms,
-               };
-       }, [config, dungeons]);
-
-       return <>
-               {dungeons.map(dungeon =>
-                       <g
-                               className={`dungeon dungeon-${dungeon.id}`}
-                               key={dungeon.id}
-                               transform={layout.transforms[dungeon.id]}
-                       >
-                               <g transform={layout.transforms.tag}>
-                                       <text className="dungeon-tag">{dungeon.id.toUpperCase()}</text>
-                               </g>
-                               {shouldShowDungeonItem(config, 'Map') ?
-                                       <ToggleIcon
-                                               controller={ToggleIcon.dungeonController(dungeon)}
-                                               icons={['map']}
-                                               svg
-                                               transform={layout.transforms.map}
-                                       />
-                               : null}
-                               {shouldShowDungeonItem(config, 'Compass') ?
-                                       <ToggleIcon
-                                               controller={ToggleIcon.dungeonController(dungeon)}
-                                               icons={['compass']}
-                                               svg
-                                               transform={layout.transforms.compass}
-                                       />
-                               : null}
-                               {shouldShowDungeonItem(config, 'Small') ?
-                                       <g transform={layout.transforms.small}>
-                                               <ToggleIcon
-                                                       controller={ToggleIcon.dungeonCountController(dungeon, dungeon.sk)}
-                                                       icons={['small-key']}
-                                                       svg
-                                               />
-                                               <CountDisplay
-                                                       count={getDungeonAcquiredSKs(state, dungeon)}
-                                                       full={dungeon.sk}
-                                               />
-                                       </g>
-                               : null}
-                               {shouldShowDungeonItem(config, 'Big') ?
-                                       <ToggleIcon
-                                               controller={ToggleIcon.dungeonController(dungeon)}
-                                               icons={['big-key']}
-                                               svg
-                                               transform={layout.transforms.big}
-                                       />
-                               : null}
-                               <g transform={layout.transforms.checks}>
-                                       <ToggleIcon
-                                               controller={ToggleIcon.dungeonCheckController(dungeon)}
-                                               icons={['open-chest', 'chest']}
-                                               svg
-                                       />
-                                       <CountDisplay count={getDungeonRemainingItems(state, dungeon)} />
-                               </g>
-                               {dungeon.boss ?
-                                       <ToggleIcon
-                                               controller={ToggleIcon.dungeonBossController(dungeon)}
-                                               icons={dungeon.bosses}
-                                               svg
-                                               transform={layout.transforms.boss}
-                                       />
-                               : null}
-                               {dungeon.prize ?
-                                       <ToggleIcon
-                                               controller={ToggleIcon.dungeonPrizeController(dungeon)}
-                                               icons={[
-                                                       'crystal',
-                                                       'red-crystal',
-                                                       'green-pendant',
-                                                       'red-pendant',
-                                               ]}
-                                               svg
-                                               transform={layout.transforms.prize}
-                                       />
-                               : null}
-                               {dungeon.id === 'gt' && config.bossShuffle ? <>
-                                       <ToggleIcon
-                                               controller={ToggleIcon.gtBossController('bot')}
-                                               icons={BOSSES}
-                                               svg
-                                               transform={layout.transforms.gtBoss1}
-                                       />
-                                       <ToggleIcon
-                                               controller={ToggleIcon.gtBossController('mid')}
-                                               icons={BOSSES}
-                                               svg
-                                               transform={layout.transforms.gtBoss2}
-                                       />
-                                       <ToggleIcon
-                                               controller={ToggleIcon.gtBossController('top')}
-                                               icons={BOSSES}
-                                               svg
-                                               transform={layout.transforms.gtBoss3}
-                                       />
-                               </> : null}
-                       </g>
-               )}
-       </>;
-};
-
-Dungeons.propTypes = {
-       columns: PropTypes.number,
-};
-
-export default Dungeons;
diff --git a/resources/js/components/tracker/Dungeons.jsx b/resources/js/components/tracker/Dungeons.jsx
new file mode 100644 (file)
index 0000000..758935c
--- /dev/null
@@ -0,0 +1,204 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import CountDisplay from './CountDisplay';
+import ToggleIcon from './ToggleIcon';
+import {
+       BOSSES,
+       getDungeonAcquiredSKs,
+       getDungeonRemainingItems,
+       shouldCompactKeysanity,
+       shouldShowDungeonItem,
+} from '../../helpers/tracker';
+import { useTracker } from '../../hooks/tracker';
+
+const Dungeons = ({ columns = 4 }) => {
+       const { config, dungeons, state } = useTracker();
+
+       const layout = React.useMemo(() => {
+               const compact = shouldCompactKeysanity(config);
+               const mapX = 1;
+               const compassX = shouldShowDungeonItem(config, 'Map') ? mapX + 1 : mapX;
+               const smallX = shouldShowDungeonItem(config, 'Compass') ? compassX + 1 : compassX;
+               const bigX =  shouldShowDungeonItem(config, 'Small') ? smallX + 1 : smallX;
+               const countX = compact ? 2 : shouldShowDungeonItem(config, 'Big') ? bigX + 1 : bigX;
+               const bossX = countX + 1;
+               const prizeX = bossX + 1;
+               const dungeonWidth = Math.max(5, prizeX + 1);
+               const width = (columns * dungeonWidth) + Math.max(0, columns - 1);
+               const height = 4;
+
+               const transform = (col, row) =>
+                       `scale(${1 / width}) translate(${
+                               (col * dungeonWidth) + 0.5 + (col ? col - (columns > 3 ? 1 : 0) : 0)
+                       } ${row + 0.5})`;
+
+               const transforms = {
+                       tag: null,
+                       map: compact
+                               ? 'translate(0.75 -0.25) scale(0.45)' : `translate(${mapX} 0) scale(0.9)`,
+                       compass: compact
+                               ? 'translate(1.25 -0.25) scale(0.45)' : `translate(${compassX} 0) scale(0.9)`,
+                       small: compact
+                               ? 'translate(0.75 0.25) scale(0.45)' : `translate(${smallX} 0) scale(0.9)`,
+                       big: compact
+                               ? 'translate(1.25 0.25) scale(0.45)' : `translate(${bigX} 0) scale(0.9)`,
+                       checks: `translate(${countX} 0) scale(0.9)`,
+                       boss: `translate(${bossX} 0)`,
+                       prize: `translate(${prizeX} 0)`,
+                       hc: transform(0, 0),
+                       ct: transform(0, 1),
+                       gt: transform(0, 2),
+                       gtBoss1: `translate(${bossX - 2} 1)`,
+                       gtBoss2: `translate(${bossX - 1} 1)`,
+                       gtBoss3: `translate(${bossX} 1)`,
+               };
+
+               if (columns === 1) {
+                       transforms.ep = transform(0, 4);
+                       transforms.dp = transform(0, 5);
+                       transforms.th = transform(0, 6);
+                       transforms.pd = transform(0, 8);
+                       transforms.sp = transform(0, 9);
+                       transforms.sw = transform(0, 10);
+                       transforms.tt = transform(0, 11);
+                       transforms.ip = transform(0, 12);
+                       transforms.mm = transform(0, 13);
+                       transforms.tr = transform(0, 14);
+               } else if (columns === 2) {
+                       transforms.ep = transform(0, 4);
+                       transforms.dp = transform(0, 5);
+                       transforms.th = transform(0, 6);
+                       transforms.pd = transform(1, 0);
+                       transforms.sp = transform(1, 1);
+                       transforms.sw = transform(1, 2);
+                       transforms.tt = transform(1, 3);
+                       transforms.ip = transform(1, 4);
+                       transforms.mm = transform(1, 5);
+                       transforms.tr = transform(1, 6);
+               } else {
+                       transforms.ep = transform(1, 0);
+                       transforms.dp = transform(1, 1);
+                       transforms.th = transform(1, 2);
+                       transforms.pd = transform(2, 0);
+                       transforms.sp = transform(2, 1);
+                       transforms.sw = transform(2, 2);
+                       transforms.tt = transform(2, 3);
+                       transforms.ip = transform(3, 0);
+                       transforms.mm = transform(3, 1);
+                       transforms.tr = transform(3, 2);
+               }
+
+               return {
+                       width,
+                       height,
+                       transforms,
+               };
+       }, [config, dungeons]);
+
+       return <>
+               {dungeons.map(dungeon =>
+                       <g
+                               className={`dungeon dungeon-${dungeon.id}`}
+                               key={dungeon.id}
+                               transform={layout.transforms[dungeon.id]}
+                       >
+                               <g transform={layout.transforms.tag}>
+                                       <text className="dungeon-tag">{dungeon.id.toUpperCase()}</text>
+                               </g>
+                               {shouldShowDungeonItem(config, 'Map') ?
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonController(dungeon)}
+                                               icons={['map']}
+                                               svg
+                                               transform={layout.transforms.map}
+                                       />
+                               : null}
+                               {shouldShowDungeonItem(config, 'Compass') ?
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonController(dungeon)}
+                                               icons={['compass']}
+                                               svg
+                                               transform={layout.transforms.compass}
+                                       />
+                               : null}
+                               {shouldShowDungeonItem(config, 'Small') ?
+                                       <g transform={layout.transforms.small}>
+                                               <ToggleIcon
+                                                       controller={ToggleIcon.dungeonCountController(dungeon, dungeon.sk)}
+                                                       icons={['small-key']}
+                                                       svg
+                                               />
+                                               <CountDisplay
+                                                       count={getDungeonAcquiredSKs(state, dungeon)}
+                                                       full={dungeon.sk}
+                                               />
+                                       </g>
+                               : null}
+                               {shouldShowDungeonItem(config, 'Big') ?
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonController(dungeon)}
+                                               icons={['big-key']}
+                                               svg
+                                               transform={layout.transforms.big}
+                                       />
+                               : null}
+                               <g transform={layout.transforms.checks}>
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonCheckController(dungeon)}
+                                               icons={['open-chest', 'chest']}
+                                               svg
+                                       />
+                                       <CountDisplay count={getDungeonRemainingItems(state, dungeon)} />
+                               </g>
+                               {dungeon.boss ?
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonBossController(dungeon)}
+                                               icons={dungeon.bosses}
+                                               svg
+                                               transform={layout.transforms.boss}
+                                       />
+                               : null}
+                               {dungeon.prize ?
+                                       <ToggleIcon
+                                               controller={ToggleIcon.dungeonPrizeController(dungeon)}
+                                               icons={[
+                                                       'crystal',
+                                                       'red-crystal',
+                                                       'green-pendant',
+                                                       'red-pendant',
+                                               ]}
+                                               svg
+                                               transform={layout.transforms.prize}
+                                       />
+                               : null}
+                               {dungeon.id === 'gt' && config.bossShuffle ? <>
+                                       <ToggleIcon
+                                               controller={ToggleIcon.gtBossController('bot')}
+                                               icons={BOSSES}
+                                               svg
+                                               transform={layout.transforms.gtBoss1}
+                                       />
+                                       <ToggleIcon
+                                               controller={ToggleIcon.gtBossController('mid')}
+                                               icons={BOSSES}
+                                               svg
+                                               transform={layout.transforms.gtBoss2}
+                                       />
+                                       <ToggleIcon
+                                               controller={ToggleIcon.gtBossController('top')}
+                                               icons={BOSSES}
+                                               svg
+                                               transform={layout.transforms.gtBoss3}
+                                       />
+                               </> : null}
+                       </g>
+               )}
+       </>;
+};
+
+Dungeons.propTypes = {
+       columns: PropTypes.number,
+};
+
+export default Dungeons;
diff --git a/resources/js/components/tracker/Equipment.js b/resources/js/components/tracker/Equipment.js
deleted file mode 100644 (file)
index 8877830..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react';
-
-import ToggleIcon from './ToggleIcon';
-
-const Equipment = () => {
-       return <div className="equipment">
-               <div className="item">
-                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['boots']} />
-               </div>
-               <div className="item">
-                       <ToggleIcon
-                               controller={ToggleIcon.progressiveController('lift', 0, 2)}
-                               icons={['glove', 'mitts']}
-                       />
-               </div>
-               <div className="item">
-                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['flippers']} />
-               </div>
-               <div className="item">
-                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['moonpearl']} />
-               </div>
-               <div className="item">
-                       <ToggleIcon
-                               controller={ToggleIcon.simpleController}
-                               icons={['half-magic', 'quarter-magic']}
-                       />
-               </div>
-               <div className="item">
-                       <ToggleIcon
-                               controller={ToggleIcon.progressiveController('sword', 0, 4)}
-                               icons={['sword-1', 'sword-2', 'sword-3', 'sword-4']}
-                       />
-               </div>
-               <div className="item">
-                       <ToggleIcon
-                               controller={ToggleIcon.progressiveController('shield', 0, 3)}
-                               icons={['fighter-shield', 'fire-shield', 'mirror-shield']}
-                       />
-               </div>
-               <div className="item">
-                       <ToggleIcon
-                               controller={ToggleIcon.progressiveController('mail', 1, 3)}
-                               icons={['green-mail', 'blue-mail', 'red-mail']}
-                       />
-               </div>
-               <div className="item">
-                       <ToggleIcon
-                               controller={ToggleIcon.modulusController('heart-piece')}
-                               icons={['heart-0', 'heart-1', 'heart-2', 'heart-3']}
-                       />
-               </div>
-       </div>;
-};
-
-export default Equipment;
diff --git a/resources/js/components/tracker/Equipment.jsx b/resources/js/components/tracker/Equipment.jsx
new file mode 100644 (file)
index 0000000..8877830
--- /dev/null
@@ -0,0 +1,55 @@
+import React from 'react';
+
+import ToggleIcon from './ToggleIcon';
+
+const Equipment = () => {
+       return <div className="equipment">
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['boots']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.progressiveController('lift', 0, 2)}
+                               icons={['glove', 'mitts']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['flippers']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon controller={ToggleIcon.simpleController} icons={['moonpearl']} />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.simpleController}
+                               icons={['half-magic', 'quarter-magic']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.progressiveController('sword', 0, 4)}
+                               icons={['sword-1', 'sword-2', 'sword-3', 'sword-4']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.progressiveController('shield', 0, 3)}
+                               icons={['fighter-shield', 'fire-shield', 'mirror-shield']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.progressiveController('mail', 1, 3)}
+                               icons={['green-mail', 'blue-mail', 'red-mail']}
+                       />
+               </div>
+               <div className="item">
+                       <ToggleIcon
+                               controller={ToggleIcon.modulusController('heart-piece')}
+                               icons={['heart-0', 'heart-1', 'heart-2', 'heart-3']}
+                       />
+               </div>
+       </div>;
+};
+
+export default Equipment;
diff --git a/resources/js/components/tracker/Items.js b/resources/js/components/tracker/Items.js
deleted file mode 100644 (file)
index e991d0b..0000000
+++ /dev/null
@@ -1,241 +0,0 @@
-import React from 'react';
-
-import ToggleIcon from './ToggleIcon';
-import { BOTTLE_CONTENTS } from '../../helpers/tracker';
-import { useTracker } from '../../hooks/tracker';
-
-const transform = (x, y, s) => `translate(${x * 0.2} ${y * 0.2}) scale(${(s || 0.85) * 0.2})`;
-
-const Items = () => {
-       const { state } = useTracker();
-
-       return <>
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['bow', 'silvers']}
-                       svg
-                       transform={transform(0.5, 0.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['blue-boomerang']}
-                       svg
-                       transform={transform(1.35, 0.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['red-boomerang']}
-                       svg
-                       transform={transform(1.85, 0.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['hookshot']}
-                       svg
-                       transform={transform(2.5, 0.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['bomb']}
-                       svg
-                       transform={transform(3.5, 0.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['powder']}
-                       svg
-                       transform={transform(4.5, 0.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['fire-rod']}
-                       svg
-                       transform={transform(0.5, 1.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['ice-rod']}
-                       svg
-                       transform={transform(1.5, 1.5)}
-               />
-               <g transform={transform(2.5, 1.5)}>
-                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['bombos']} svg />
-                       {state['mm-medallion'] === 'bombos' ?
-                               <text className="med-display bottom-left">MM</text>
-                       : null}
-                       {state['tr-medallion'] === 'bombos' ?
-                               <text className="med-display bottom-right">TR</text>
-                       : null}
-               </g>
-               <g transform={transform(3.5, 1.5)}>
-                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['ether']} svg />
-                       {state['mm-medallion'] === 'ether' ?
-                               <text className="med-display bottom-left">MM</text>
-                       : null}
-                       {state['tr-medallion'] === 'ether' ?
-                               <text className="med-display bottom-right">TR</text>
-                       : null}
-               </g>
-               <g transform={transform(4.5, 1.5)}>
-                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['quake']} svg />
-                       {state['mm-medallion'] === 'quake' ?
-                               <text className="med-display bottom-left">MM</text>
-                       : null}
-                       {state['tr-medallion'] === 'quake' ?
-                               <text className="med-display bottom-right">TR</text>
-                       : null}
-               </g>
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['lamp']}
-                       svg
-                       transform={transform(0.5, 2.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['hammer']}
-                       svg
-                       transform={transform(1.5, 2.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['flute']}
-                       svg
-                       transform={transform(2.5, 2.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['duck']}
-                       svg
-                       transform={transform(2.75, 2.75, 0.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['bugnet']}
-                       svg
-                       transform={transform(3.5, 2.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['book']}
-                       svg
-                       transform={transform(4.5, 2.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['shovel']}
-                       svg
-                       transform={transform(0.5, 3.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['somaria']}
-                       svg
-                       transform={transform(1.5, 3.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['byrna']}
-                       svg
-                       transform={transform(2.5, 3.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['cape']}
-                       svg
-                       transform={transform(3.5, 3.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['mirror']}
-                       svg
-                       transform={transform(4.5, 3.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.bottleController('bottle-1')}
-                       icons={BOTTLE_CONTENTS}
-                       svg
-                       transform={transform(0.5, 5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.bottleController('bottle-2')}
-                       icons={BOTTLE_CONTENTS}
-                       svg
-                       transform={transform(1.5, 5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.bottleController('bottle-3')}
-                       icons={BOTTLE_CONTENTS}
-                       svg
-                       transform={transform(2.5, 5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.bottleController('bottle-4')}
-                       icons={BOTTLE_CONTENTS}
-                       svg
-                       transform={transform(3.5, 5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['mushroom']}
-                       svg
-                       transform={transform(4.5, 5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['boots']}
-                       svg
-                       transform={transform(0.5, 6.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.progressiveController('lift', 0, 2)}
-                       icons={['glove', 'mitts']}
-                       svg
-                       transform={transform(1.5, 6.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['flippers']}
-                       svg
-                       transform={transform(2.5, 6.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['moonpearl']}
-                       svg
-                       transform={transform(3.5, 6.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.simpleController}
-                       icons={['half-magic', 'quarter-magic']}
-                       svg
-                       transform={transform(4.5, 6.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.progressiveController('sword', 0, 4)}
-                       icons={['sword-1', 'sword-2', 'sword-3', 'sword-4']}
-                       svg
-                       transform={transform(0.5, 7.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.progressiveController('shield', 0, 3)}
-                       icons={['fighter-shield', 'fire-shield', 'mirror-shield']}
-                       svg
-                       transform={transform(1.5, 7.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.progressiveController('mail', 1, 3)}
-                       icons={['green-mail', 'blue-mail', 'red-mail']}
-                       svg
-                       transform={transform(2.5, 7.5)}
-               />
-               <ToggleIcon
-                       controller={ToggleIcon.modulusController('heart-piece')}
-                       icons={['heart-0', 'heart-1', 'heart-2', 'heart-3']}
-                       svg
-                       transform={transform(3.5, 7.5)}
-               />
-       </>;
-};
-
-export default Items;
diff --git a/resources/js/components/tracker/Items.jsx b/resources/js/components/tracker/Items.jsx
new file mode 100644 (file)
index 0000000..e991d0b
--- /dev/null
@@ -0,0 +1,241 @@
+import React from 'react';
+
+import ToggleIcon from './ToggleIcon';
+import { BOTTLE_CONTENTS } from '../../helpers/tracker';
+import { useTracker } from '../../hooks/tracker';
+
+const transform = (x, y, s) => `translate(${x * 0.2} ${y * 0.2}) scale(${(s || 0.85) * 0.2})`;
+
+const Items = () => {
+       const { state } = useTracker();
+
+       return <>
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['bow', 'silvers']}
+                       svg
+                       transform={transform(0.5, 0.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['blue-boomerang']}
+                       svg
+                       transform={transform(1.35, 0.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['red-boomerang']}
+                       svg
+                       transform={transform(1.85, 0.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['hookshot']}
+                       svg
+                       transform={transform(2.5, 0.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['bomb']}
+                       svg
+                       transform={transform(3.5, 0.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['powder']}
+                       svg
+                       transform={transform(4.5, 0.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['fire-rod']}
+                       svg
+                       transform={transform(0.5, 1.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['ice-rod']}
+                       svg
+                       transform={transform(1.5, 1.5)}
+               />
+               <g transform={transform(2.5, 1.5)}>
+                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['bombos']} svg />
+                       {state['mm-medallion'] === 'bombos' ?
+                               <text className="med-display bottom-left">MM</text>
+                       : null}
+                       {state['tr-medallion'] === 'bombos' ?
+                               <text className="med-display bottom-right">TR</text>
+                       : null}
+               </g>
+               <g transform={transform(3.5, 1.5)}>
+                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['ether']} svg />
+                       {state['mm-medallion'] === 'ether' ?
+                               <text className="med-display bottom-left">MM</text>
+                       : null}
+                       {state['tr-medallion'] === 'ether' ?
+                               <text className="med-display bottom-right">TR</text>
+                       : null}
+               </g>
+               <g transform={transform(4.5, 1.5)}>
+                       <ToggleIcon controller={ToggleIcon.medallionController} icons={['quake']} svg />
+                       {state['mm-medallion'] === 'quake' ?
+                               <text className="med-display bottom-left">MM</text>
+                       : null}
+                       {state['tr-medallion'] === 'quake' ?
+                               <text className="med-display bottom-right">TR</text>
+                       : null}
+               </g>
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['lamp']}
+                       svg
+                       transform={transform(0.5, 2.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['hammer']}
+                       svg
+                       transform={transform(1.5, 2.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['flute']}
+                       svg
+                       transform={transform(2.5, 2.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['duck']}
+                       svg
+                       transform={transform(2.75, 2.75, 0.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['bugnet']}
+                       svg
+                       transform={transform(3.5, 2.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['book']}
+                       svg
+                       transform={transform(4.5, 2.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['shovel']}
+                       svg
+                       transform={transform(0.5, 3.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['somaria']}
+                       svg
+                       transform={transform(1.5, 3.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['byrna']}
+                       svg
+                       transform={transform(2.5, 3.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['cape']}
+                       svg
+                       transform={transform(3.5, 3.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['mirror']}
+                       svg
+                       transform={transform(4.5, 3.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.bottleController('bottle-1')}
+                       icons={BOTTLE_CONTENTS}
+                       svg
+                       transform={transform(0.5, 5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.bottleController('bottle-2')}
+                       icons={BOTTLE_CONTENTS}
+                       svg
+                       transform={transform(1.5, 5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.bottleController('bottle-3')}
+                       icons={BOTTLE_CONTENTS}
+                       svg
+                       transform={transform(2.5, 5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.bottleController('bottle-4')}
+                       icons={BOTTLE_CONTENTS}
+                       svg
+                       transform={transform(3.5, 5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['mushroom']}
+                       svg
+                       transform={transform(4.5, 5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['boots']}
+                       svg
+                       transform={transform(0.5, 6.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.progressiveController('lift', 0, 2)}
+                       icons={['glove', 'mitts']}
+                       svg
+                       transform={transform(1.5, 6.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['flippers']}
+                       svg
+                       transform={transform(2.5, 6.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['moonpearl']}
+                       svg
+                       transform={transform(3.5, 6.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.simpleController}
+                       icons={['half-magic', 'quarter-magic']}
+                       svg
+                       transform={transform(4.5, 6.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.progressiveController('sword', 0, 4)}
+                       icons={['sword-1', 'sword-2', 'sword-3', 'sword-4']}
+                       svg
+                       transform={transform(0.5, 7.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.progressiveController('shield', 0, 3)}
+                       icons={['fighter-shield', 'fire-shield', 'mirror-shield']}
+                       svg
+                       transform={transform(1.5, 7.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.progressiveController('mail', 1, 3)}
+                       icons={['green-mail', 'blue-mail', 'red-mail']}
+                       svg
+                       transform={transform(2.5, 7.5)}
+               />
+               <ToggleIcon
+                       controller={ToggleIcon.modulusController('heart-piece')}
+                       icons={['heart-0', 'heart-1', 'heart-2', 'heart-3']}
+                       svg
+                       transform={transform(3.5, 7.5)}
+               />
+       </>;
+};
+
+export default Items;
diff --git a/resources/js/components/tracker/Map/Overworld.js b/resources/js/components/tracker/Map/Overworld.js
deleted file mode 100644 (file)
index f899593..0000000
+++ /dev/null
@@ -1,880 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { useTranslation } from 'react-i18next';
-
-import {
-       addDungeonCheck,
-       aggregateDungeonStatus,
-       aggregateLocationStatus,
-       clearAll,
-       completeDungeonChecks,
-       countRemainingLocations,
-       getDungeonClearedItems,
-       getDungeonRemainingItems,
-       hasDungeonBoss,
-       hasDungeonPrize,
-       isDungeonCleared,
-       removeDungeonCheck,
-       resetDungeonChecks,
-       setBossDefeated,
-       setPrizeAcquired,
-       unclearAll,
-} from '../../../helpers/tracker';
-import { useTracker } from '../../../hooks/tracker';
-
-const GENERIC_LW_DUNGEONS = [
-       {
-               id: 'hc',
-               x: 0.5,
-               y: 0.5,
-       },
-       {
-               id: 'ep',
-               x: 0.95,
-               y: 0.42,
-       },
-       {
-               id: 'dp',
-               x: 0.075,
-               y: 0.8,
-       },
-       {
-               id: 'th',
-               x: 0.56,
-               y: 0.05,
-       },
-];
-
-const LW_DUNGEONS = [
-       ...GENERIC_LW_DUNGEONS,
-       {
-               id: 'ct',
-               x: 0.5,
-               y: 0.4,
-       },
-];
-
-const INVERTED_LW_DUNGEONS = [
-       ...GENERIC_LW_DUNGEONS,
-       {
-               id: 'gt',
-               x: 0.5,
-               y: 0.4,
-       },
-];
-
-const GENERIC_LW_LOCATIONS = [
-       {
-               id: 'aginah',
-               checks: [
-                       'aginah',
-               ],
-               x: 0.2,
-               y: 0.83,
-       },
-       {
-               id: 'blinds-hut',
-               checks: [
-                       'blinds-hut-top',
-                       'blinds-hut-left',
-                       'blinds-hut-right',
-                       'blinds-hut-far-left',
-                       'blinds-hut-far-right',
-               ],
-               x: 0.14,
-               y: 0.42,
-       },
-       {
-               id: 'bombos-tablet',
-               checks: [
-                       'bombos-tablet',
-               ],
-               x: 0.225,
-               y: 0.925,
-       },
-       {
-               id: 'bonk-rocks',
-               checks: [
-                       'bonk-rocks',
-               ],
-               x: 0.4,
-               y: 0.3,
-       },
-       {
-               id: 'bottle-vendor',
-               checks: [
-                       'bottle-vendor',
-               ],
-               x: 0.1,
-               y: 0.475,
-       },
-       {
-               id: 'cave-45',
-               checks: [
-                       'cave-45',
-               ],
-               x: 0.27,
-               y: 0.83,
-       },
-       {
-               id: 'checkerboard',
-               checks: [
-                       'checkerboard',
-               ],
-               x: 0.18,
-               y: 0.78,
-       },
-       {
-               id: 'chicken-house',
-               checks: [
-                       'chicken-house',
-               ],
-               x: 0.1,
-               y: 0.53,
-       },
-       {
-               id: 'dam',
-               checks: [
-                       'flooded-chest',
-                       'sunken-treasure',
-               ],
-               x: 0.4675,
-               y: 0.9375,
-       },
-       {
-               id: 'desert-ledge',
-               checks: [
-                       'desert-ledge',
-               ],
-               x: 0.025,
-               y: 0.9,
-       },
-       {
-               id: 'ether-tablet',
-               checks: [
-                       'ether-tablet',
-               ],
-               x: 0.425,
-               y: 0.025,
-       },
-       {
-               id: 'floating-island',
-               checks: [
-                       'floating-island',
-               ],
-               x: 0.8,
-               y: 0.025,
-       },
-       {
-               id: 'flute-spot',
-               checks: [
-                       'flute-spot',
-               ],
-               x: 0.3,
-               y: 0.675,
-       },
-       {
-               id: 'graveyard-ledge',
-               checks: [
-                       'graveyard-ledge',
-               ],
-               x: 0.57,
-               y: 0.28,
-       },
-       {
-               id: 'hobo',
-               checks: [
-                       'hobo',
-               ],
-               x: 0.7,
-               y: 0.7,
-       },
-       {
-               id: 'ice-rod-cave',
-               checks: [
-                       'ice-rod-cave',
-               ],
-               x: 0.9,
-               y: 0.76,
-       },
-       {
-               id: 'kak-well',
-               checks: [
-                       'kak-well-top',
-                       'kak-well-left',
-                       'kak-well-mid',
-                       'kak-well-right',
-                       'kak-well-bottom',
-               ],
-               x: 0.04,
-               y: 0.425,
-       },
-       {
-               id: 'kings-tomb',
-               checks: [
-                       'kings-tomb',
-               ],
-               x: 0.62,
-               y: 0.3,
-       },
-       {
-               id: 'lake-hylia-island',
-               checks: [
-                       'lake-hylia-island',
-               ],
-               x: 0.725,
-               y: 0.8375,
-       },
-       {
-               id: 'library',
-               checks: [
-                       'library',
-               ],
-               x: 0.15,
-               y: 0.65,
-       },
-       {
-               id: 'lost-woods-hideout',
-               checks: [
-                       'lost-woods-hideout',
-               ],
-               x: 0.19,
-               y: 0.14,
-       },
-       {
-               id: 'lumberjack',
-               checks: [
-                       'lumberjack',
-               ],
-               x: 0.3,
-               y: 0.07,
-       },
-       {
-               id: 'magic-bat',
-               checks: [
-                       'magic-bat',
-               ],
-               x: 0.325,
-               y: 0.55,
-       },
-       {
-               id: 'mimic-cave',
-               checks: [
-                       'mimic-cave',
-               ],
-               x: 0.85,
-               y: 0.1,
-       },
-       {
-               id: 'mini-moldorm-cave',
-               checks: [
-                       'mini-moldorm-left',
-                       'mini-moldorm-right',
-                       'mini-moldorm-far-left',
-                       'mini-moldorm-far-right',
-                       'mini-moldorm-npc',
-               ],
-               x: 0.65,
-               y: 0.95,
-       },
-       {
-               id: 'mushroom-spot',
-               checks: [
-                       'mushroom-spot',
-               ],
-               x: 0.125,
-               y: 0.08,
-       },
-       {
-               id: 'old-man',
-               checks: [
-                       'old-man',
-               ],
-               x: 0.405,
-               y: 0.195,
-       },
-       {
-               id: 'paradox-cave',
-               checks: [
-                       'paradox-lower-far-left',
-                       'paradox-lower-left',
-                       'paradox-lower-right',
-                       'paradox-lower-far-right',
-                       'paradox-lower-mid',
-                       'paradox-upper-left',
-                       'paradox-upper-right',
-               ],
-               x: 0.85,
-               y: 0.2,
-       },
-       {
-               id: 'pedestal',
-               checks: [
-                       'pedestal',
-               ],
-               x: 0.03,
-               y: 0.05,
-       },
-       {
-               id: 'potion-shop',
-               checks: [
-                       'potion-shop',
-               ],
-               x: 0.8,
-               y: 0.325,
-       },
-       {
-               id: 'race-game',
-               checks: [
-                       'race-game',
-               ],
-               x: 0.025,
-               y: 0.7,
-       },
-       {
-               id: 'saha',
-               checks: [
-                       'saha',
-               ],
-               x: 0.815,
-               y: 0.465,
-       },
-       {
-               id: 'saha-hut',
-               checks: [
-                       'saha-left',
-                       'saha-mid',
-                       'saha-right',
-               ],
-               x: 0.815,
-               y: 0.42,
-       },
-       {
-               id: 'sick-kid',
-               checks: [
-                       'sick-kid',
-               ],
-               x: 0.155,
-               y: 0.525,
-       },
-       {
-               id: 'uncle',
-               checks: [
-                       'uncle',
-                       'secret-passage',
-               ],
-               x: 0.6,
-               y: 0.4,
-       },
-       {
-               id: 'spec-rock',
-               checks: [
-                       'spec-rock',
-               ],
-               x: 0.48,
-               y: 0.09,
-       },
-       {
-               id: 'spec-rock-cave',
-               checks: [
-                       'spec-rock-cave',
-               ],
-               x: 0.48,
-               y: 0.14,
-       },
-       {
-               id: 'spiral-cave',
-               checks: [
-                       'spiral-cave',
-               ],
-               x: 0.8,
-               y: 0.1,
-       },
-       {
-               id: 'tavern',
-               checks: [
-                       'tavern',
-               ],
-               x: 0.16,
-               y: 0.58,
-       },
-       {
-               id: 'waterfall-fairy',
-               checks: [
-                       'waterfall-fairy-left',
-                       'waterfall-fairy-right',
-               ],
-               x: 0.9,
-               y: 0.15,
-       },
-       {
-               id: 'zora',
-               checks: [
-                       'zora',
-               ],
-               x: 0.975,
-               y: 0.12,
-       },
-       {
-               id: 'zora-ledge',
-               checks: [
-                       'zora-ledge',
-               ],
-               x: 0.975,
-               y: 0.165,
-       },
-];
-
-const LW_LOCATIONS = [
-       ...GENERIC_LW_LOCATIONS,
-       {
-               id: 'links-house',
-               checks: [
-                       'links-house',
-               ],
-               x: 0.55,
-               y: 0.6875,
-       },
-];
-
-const INVERTED_LW_LOCATIONS = GENERIC_LW_LOCATIONS;
-
-const GENERIC_DW_DUNGEONS = [
-       {
-               id: 'pd',
-               x: 0.95,
-               y: 0.42,
-       },
-       {
-               id: 'sp',
-               x: 0.4675,
-               y: 0.9375,
-       },
-       {
-               id: 'sw',
-               x: 0.05,
-               y: 0.05,
-       },
-       {
-               id: 'tt',
-               x: 0.125,
-               y: 0.475,
-       },
-       {
-               id: 'ip',
-               x: 0.7975,
-               y: 0.86,
-       },
-       {
-               id: 'mm',
-               x: 0.12,
-               y: 0.82,
-       },
-       {
-               id: 'tr',
-               x: 0.94,
-               y: 0.06,
-       },
-];
-
-const DW_DUNGEONS = [
-       ...GENERIC_DW_DUNGEONS,
-       {
-               id: 'gt',
-               x: 0.56,
-               y: 0.05,
-       },
-];
-
-const INVERTED_DW_DUNGEONS = [
-       ...GENERIC_DW_DUNGEONS,
-       {
-               id: 'ct',
-               x: 0.56,
-               y: 0.05,
-       },
-];
-
-const GENERIC_DW_LOCATIONS = [
-       {
-               id: 'blacksmith',
-               checks: [
-                       'blacksmith',
-               ],
-               x: 0.15,
-               y: 0.65,
-       },
-       {
-               id: 'brewery',
-               checks: [
-                       'brewery',
-               ],
-               x: 0.1,
-               y: 0.6,
-       },
-       {
-               id: 'bumper-cave',
-               checks: [
-                       'bumper-cave',
-               ],
-               x: 0.325,
-               y: 0.15,
-       },
-       {
-               id: 'c-house',
-               checks: [
-                       'c-house',
-               ],
-               x: 0.2,
-               y: 0.5,
-       },
-       {
-               id: 'catfish',
-               checks: [
-                       'catfish',
-               ],
-               x: 0.9,
-               y: 0.175,
-       },
-       {
-               id: 'chest-game',
-               checks: [
-                       'chest-game',
-               ],
-               x: 0.05,
-               y: 0.45,
-       },
-       {
-               id: 'digging-game',
-               checks: [
-                       'digging-game',
-               ],
-               x: 0.05,
-               y: 0.7,
-       },
-       {
-               id: 'hammer-pegs',
-               checks: [
-                       'hammer-pegs',
-               ],
-               x: 0.3125,
-               y: 0.6,
-       },
-       {
-               id: 'hookshot-cave',
-               checks: [
-                       'hookshot-cave-tl',
-                       'hookshot-cave-tr',
-                       'hookshot-cave-bl',
-               ],
-               x: 0.85,
-               y: 0.02,
-       },
-       {
-               id: 'hookshot-cave-bonk',
-               checks: [
-                       'hookshot-cave-br',
-               ],
-               x: 0.85,
-               y: 0.065,
-       },
-       {
-               id: 'hype-cave',
-               checks: [
-                       'hype-cave-top',
-                       'hype-cave-left',
-                       'hype-cave-right',
-                       'hype-cave-bottom',
-                       'hype-cave-npc',
-               ],
-               x: 0.6,
-               y: 0.75,
-       },
-       {
-               id: 'mire-shed',
-               checks: [
-                       'mire-shed-left',
-                       'mire-shed-right',
-               ],
-               x: 0.04,
-               y: 0.8,
-       },
-       {
-               id: 'purple-chest',
-               checks: [
-                       'purple-chest',
-               ],
-               x: 0.3125,
-               y: 0.525,
-       },
-       {
-               id: 'pyramid',
-               checks: [
-                       'pyramid',
-               ],
-               x: 0.575,
-               y: 0.45,
-       },
-       {
-               id: 'pyramid-fairy',
-               checks: [
-                       'pyramid-fairy-left',
-                       'pyramid-fairy-right',
-               ],
-               x: 0.45,
-               y: 0.5,
-       },
-       {
-               id: 'spike-cave',
-               checks: [
-                       'spike-cave',
-               ],
-               x: 0.575,
-               y: 0.15,
-       },
-       {
-               id: 'stumpy',
-               checks: [
-                       'stumpy',
-               ],
-               x: 0.3125,
-               y: 0.6875,
-       },
-       {
-               id: 'super-bunny',
-               checks: [
-                       'super-bunny-top',
-                       'super-bunny-bottom',
-               ],
-               x: 0.85,
-               y: 0.15,
-       },
-];
-
-const DW_LOCATIONS = GENERIC_DW_LOCATIONS;
-
-const INVERTED_DW_LOCATIONS = [
-       ...GENERIC_DW_LOCATIONS,
-       {
-               id: 'links-house',
-               checks: [
-                       'links-house',
-               ],
-               x: 0.55,
-               y: 0.6875,
-       },
-];
-
-const Location = ({ number, l, size }) => {
-       const { t } = useTranslation();
-
-       const classNames = ['location', `status-${l.status}`];
-       if (size) {
-               classNames.push(`size-${size}`);
-       }
-       if (l.handlePrimary) {
-               classNames.push('clickable');
-       }
-
-       return <g
-               className={classNames.join(' ')}
-               onClick={(e) => {
-                       l.handlePrimary();
-                       e.preventDefault();
-                       e.stopPropagation();
-               }}
-               onContextMenu={(e) => {
-                       l.handleSecondary();
-                       e.preventDefault();
-                       e.stopPropagation();
-               }}
-               transform={`translate(${l.x} ${l.y})`}
-       >
-               <title>{t(`tracker.location.${l.id}`)}</title>
-               <rect className="box" x="0" y="0" />
-               {number && l.remaining ?
-                       <text className="text" x="0" y="0">{l.remaining}</text>
-               : null}
-       </g>;
-};
-
-Location.propTypes = {
-       number: PropTypes.bool,
-       l: PropTypes.shape({
-               id: PropTypes.string,
-               x: PropTypes.number,
-               y: PropTypes.number,
-               number: PropTypes.number,
-               remaining: PropTypes.number,
-               status: PropTypes.string,
-               handlePrimary: PropTypes.func,
-               handleSecondary: PropTypes.func,
-       }),
-       size: PropTypes.string,
-};
-
-const makeBackground = (src, level) => {
-       const amount = Math.pow(2, Math.max(0, level - 8));
-       const size = 1 / amount;
-       const tiles = [];
-       for (let y = 0; y < amount; ++y) {
-               for (let x = 0; x < amount; ++x) {
-                       tiles.push(<image
-                               key={`${x}-${y}`}
-                               x={x * size}
-                               y={y * size}
-                               width={size * 1.002}
-                               height={size * 1.002}
-                               href={`/media/alttp/map/${src}/${level}/${x}_${y}.png`}
-                       />);
-               }
-       }
-       return tiles;
-};
-
-const Overworld = () => {
-       const { config, dungeons, logic, setManualState, state } = useTracker();
-
-       const mapDungeon = React.useCallback(dungeon => {
-               const definition = dungeons.find(d => d.id === dungeon.id);
-               const remaining = getDungeonRemainingItems(state, definition);
-               const status = aggregateDungeonStatus(definition, logic, state);
-               return {
-                       ...dungeon,
-                       status,
-                       remaining,
-                       handlePrimary: () => {
-                               if (getDungeonRemainingItems(state, definition)) {
-                                       setManualState(addDungeonCheck(definition));
-                               } else if (
-                                       !hasDungeonBoss(state, definition) || !hasDungeonPrize(state, definition)
-                               ) {
-                                       if (definition.boss) {
-                                               setManualState(setBossDefeated(definition, true));
-                                       }
-                                       if (definition.prize) {
-                                               setManualState(setPrizeAcquired(definition, true));
-                                       }
-                               } else {
-                                       setManualState(resetDungeonChecks(definition));
-                                       if (definition.boss) {
-                                               setManualState(setBossDefeated(definition, false));
-                                       }
-                                       if (definition.prize) {
-                                               setManualState(setPrizeAcquired(definition, false));
-                                       }
-                               }
-                       },
-                       handleSecondary: () => {
-                               if (isDungeonCleared(state, definition)) {
-                                       if (definition.items) {
-                                               setManualState(removeDungeonCheck(definition));
-                                       }
-                                       if (definition.boss) {
-                                               setManualState(setBossDefeated(definition, false));
-                                       }
-                                       if (definition.prize) {
-                                               setManualState(setPrizeAcquired(definition, false));
-                                       }
-                               } else if (getDungeonClearedItems(state, definition)) {
-                                       setManualState(removeDungeonCheck(definition));
-                               } else {
-                                       setManualState(completeDungeonChecks(definition));
-                                       if (definition.boss) {
-                                               setManualState(setBossDefeated(definition, true));
-                                       }
-                                       if (definition.prize) {
-                                               setManualState(setPrizeAcquired(definition, true));
-                                       }
-                               }
-                       },
-               };
-       }, [dungeons, logic, setManualState, state]);
-
-       const mapLocation = React.useCallback(loc => {
-               const remaining = countRemainingLocations(state, loc.checks);
-               const status = aggregateLocationStatus(loc.checks, logic, state);
-               return {
-                       ...loc,
-                       remaining,
-                       status,
-                       handlePrimary: () => {
-                               if (remaining) {
-                                       setManualState(clearAll(loc.checks));
-                               } else {
-                                       setManualState(unclearAll(loc.checks));
-                               }
-                       },
-                       handleSecondary: () => {
-                               if (remaining) {
-                                       setManualState(clearAll(loc.checks));
-                               } else {
-                                       setManualState(unclearAll(loc.checks));
-                               }
-                       },
-               };
-       }, [logic, setManualState, state]);
-
-       const lwDungeons = React.useMemo(() =>
-               (config.worldState === 'inverted' ? INVERTED_LW_DUNGEONS : LW_DUNGEONS)
-               .map(mapDungeon)
-       , [mapDungeon]);
-       const lwLocations = React.useMemo(() =>
-               (config.worldState === 'inverted' ? INVERTED_LW_LOCATIONS : LW_LOCATIONS)
-               .map(mapLocation)
-       , [mapLocation]);
-
-       const dwDungeons = React.useMemo(() =>
-               (config.worldState === 'inverted' ? INVERTED_DW_DUNGEONS : DW_DUNGEONS)
-               .map(mapDungeon)
-       , [mapDungeon]);
-       const dwLocations = React.useMemo(() =>
-               (config.worldState === 'inverted' ? INVERTED_DW_LOCATIONS : DW_LOCATIONS)
-               .map(mapLocation)
-       , [mapLocation]);
-
-       const layout = React.useMemo(() => {
-               if (config.mapLayout === 'vertical') {
-                       return {
-                               lwTransform: '',
-                               dwTransform: 'translate(0 1)',
-                       };
-               } else {
-                       return {
-                               lwTransform: 'scale(0.5)',
-                               dwTransform: 'scale(0.5) translate(1 0)',
-                       };
-               }
-       }, [config]);
-
-       return <>
-               <g className="light-world" transform={layout.lwTransform}>
-                       <g className="background">
-                               {makeBackground('lw_files', 10)}
-                       </g>
-                       <g className="locations">
-                               {lwLocations.map(l =>
-                                       <Location key={l.id} l={l} />
-                               )}
-                               {lwDungeons.map(l =>
-                                       <Location key={l.id} number l={l} size="lg" />
-                               )}
-                       </g>
-               </g>
-               <g className="dark-world" transform={layout.dwTransform}>
-                       <g className="background">
-                               {makeBackground('dw_files', 10)}
-                       </g>
-                       <g className="locations">
-                               {dwLocations.map(l =>
-                                       <Location key={l.id} l={l} />
-                               )}
-                               {dwDungeons.map(l =>
-                                       <Location key={l.id} number l={l} size="lg" />
-                               )}
-                       </g>
-               </g>
-       </>;
-};
-
-export default Overworld;
diff --git a/resources/js/components/tracker/Map/Overworld.jsx b/resources/js/components/tracker/Map/Overworld.jsx
new file mode 100644 (file)
index 0000000..f899593
--- /dev/null
@@ -0,0 +1,880 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import {
+       addDungeonCheck,
+       aggregateDungeonStatus,
+       aggregateLocationStatus,
+       clearAll,
+       completeDungeonChecks,
+       countRemainingLocations,
+       getDungeonClearedItems,
+       getDungeonRemainingItems,
+       hasDungeonBoss,
+       hasDungeonPrize,
+       isDungeonCleared,
+       removeDungeonCheck,
+       resetDungeonChecks,
+       setBossDefeated,
+       setPrizeAcquired,
+       unclearAll,
+} from '../../../helpers/tracker';
+import { useTracker } from '../../../hooks/tracker';
+
+const GENERIC_LW_DUNGEONS = [
+       {
+               id: 'hc',
+               x: 0.5,
+               y: 0.5,
+       },
+       {
+               id: 'ep',
+               x: 0.95,
+               y: 0.42,
+       },
+       {
+               id: 'dp',
+               x: 0.075,
+               y: 0.8,
+       },
+       {
+               id: 'th',
+               x: 0.56,
+               y: 0.05,
+       },
+];
+
+const LW_DUNGEONS = [
+       ...GENERIC_LW_DUNGEONS,
+       {
+               id: 'ct',
+               x: 0.5,
+               y: 0.4,
+       },
+];
+
+const INVERTED_LW_DUNGEONS = [
+       ...GENERIC_LW_DUNGEONS,
+       {
+               id: 'gt',
+               x: 0.5,
+               y: 0.4,
+       },
+];
+
+const GENERIC_LW_LOCATIONS = [
+       {
+               id: 'aginah',
+               checks: [
+                       'aginah',
+               ],
+               x: 0.2,
+               y: 0.83,
+       },
+       {
+               id: 'blinds-hut',
+               checks: [
+                       'blinds-hut-top',
+                       'blinds-hut-left',
+                       'blinds-hut-right',
+                       'blinds-hut-far-left',
+                       'blinds-hut-far-right',
+               ],
+               x: 0.14,
+               y: 0.42,
+       },
+       {
+               id: 'bombos-tablet',
+               checks: [
+                       'bombos-tablet',
+               ],
+               x: 0.225,
+               y: 0.925,
+       },
+       {
+               id: 'bonk-rocks',
+               checks: [
+                       'bonk-rocks',
+               ],
+               x: 0.4,
+               y: 0.3,
+       },
+       {
+               id: 'bottle-vendor',
+               checks: [
+                       'bottle-vendor',
+               ],
+               x: 0.1,
+               y: 0.475,
+       },
+       {
+               id: 'cave-45',
+               checks: [
+                       'cave-45',
+               ],
+               x: 0.27,
+               y: 0.83,
+       },
+       {
+               id: 'checkerboard',
+               checks: [
+                       'checkerboard',
+               ],
+               x: 0.18,
+               y: 0.78,
+       },
+       {
+               id: 'chicken-house',
+               checks: [
+                       'chicken-house',
+               ],
+               x: 0.1,
+               y: 0.53,
+       },
+       {
+               id: 'dam',
+               checks: [
+                       'flooded-chest',
+                       'sunken-treasure',
+               ],
+               x: 0.4675,
+               y: 0.9375,
+       },
+       {
+               id: 'desert-ledge',
+               checks: [
+                       'desert-ledge',
+               ],
+               x: 0.025,
+               y: 0.9,
+       },
+       {
+               id: 'ether-tablet',
+               checks: [
+                       'ether-tablet',
+               ],
+               x: 0.425,
+               y: 0.025,
+       },
+       {
+               id: 'floating-island',
+               checks: [
+                       'floating-island',
+               ],
+               x: 0.8,
+               y: 0.025,
+       },
+       {
+               id: 'flute-spot',
+               checks: [
+                       'flute-spot',
+               ],
+               x: 0.3,
+               y: 0.675,
+       },
+       {
+               id: 'graveyard-ledge',
+               checks: [
+                       'graveyard-ledge',
+               ],
+               x: 0.57,
+               y: 0.28,
+       },
+       {
+               id: 'hobo',
+               checks: [
+                       'hobo',
+               ],
+               x: 0.7,
+               y: 0.7,
+       },
+       {
+               id: 'ice-rod-cave',
+               checks: [
+                       'ice-rod-cave',
+               ],
+               x: 0.9,
+               y: 0.76,
+       },
+       {
+               id: 'kak-well',
+               checks: [
+                       'kak-well-top',
+                       'kak-well-left',
+                       'kak-well-mid',
+                       'kak-well-right',
+                       'kak-well-bottom',
+               ],
+               x: 0.04,
+               y: 0.425,
+       },
+       {
+               id: 'kings-tomb',
+               checks: [
+                       'kings-tomb',
+               ],
+               x: 0.62,
+               y: 0.3,
+       },
+       {
+               id: 'lake-hylia-island',
+               checks: [
+                       'lake-hylia-island',
+               ],
+               x: 0.725,
+               y: 0.8375,
+       },
+       {
+               id: 'library',
+               checks: [
+                       'library',
+               ],
+               x: 0.15,
+               y: 0.65,
+       },
+       {
+               id: 'lost-woods-hideout',
+               checks: [
+                       'lost-woods-hideout',
+               ],
+               x: 0.19,
+               y: 0.14,
+       },
+       {
+               id: 'lumberjack',
+               checks: [
+                       'lumberjack',
+               ],
+               x: 0.3,
+               y: 0.07,
+       },
+       {
+               id: 'magic-bat',
+               checks: [
+                       'magic-bat',
+               ],
+               x: 0.325,
+               y: 0.55,
+       },
+       {
+               id: 'mimic-cave',
+               checks: [
+                       'mimic-cave',
+               ],
+               x: 0.85,
+               y: 0.1,
+       },
+       {
+               id: 'mini-moldorm-cave',
+               checks: [
+                       'mini-moldorm-left',
+                       'mini-moldorm-right',
+                       'mini-moldorm-far-left',
+                       'mini-moldorm-far-right',
+                       'mini-moldorm-npc',
+               ],
+               x: 0.65,
+               y: 0.95,
+       },
+       {
+               id: 'mushroom-spot',
+               checks: [
+                       'mushroom-spot',
+               ],
+               x: 0.125,
+               y: 0.08,
+       },
+       {
+               id: 'old-man',
+               checks: [
+                       'old-man',
+               ],
+               x: 0.405,
+               y: 0.195,
+       },
+       {
+               id: 'paradox-cave',
+               checks: [
+                       'paradox-lower-far-left',
+                       'paradox-lower-left',
+                       'paradox-lower-right',
+                       'paradox-lower-far-right',
+                       'paradox-lower-mid',
+                       'paradox-upper-left',
+                       'paradox-upper-right',
+               ],
+               x: 0.85,
+               y: 0.2,
+       },
+       {
+               id: 'pedestal',
+               checks: [
+                       'pedestal',
+               ],
+               x: 0.03,
+               y: 0.05,
+       },
+       {
+               id: 'potion-shop',
+               checks: [
+                       'potion-shop',
+               ],
+               x: 0.8,
+               y: 0.325,
+       },
+       {
+               id: 'race-game',
+               checks: [
+                       'race-game',
+               ],
+               x: 0.025,
+               y: 0.7,
+       },
+       {
+               id: 'saha',
+               checks: [
+                       'saha',
+               ],
+               x: 0.815,
+               y: 0.465,
+       },
+       {
+               id: 'saha-hut',
+               checks: [
+                       'saha-left',
+                       'saha-mid',
+                       'saha-right',
+               ],
+               x: 0.815,
+               y: 0.42,
+       },
+       {
+               id: 'sick-kid',
+               checks: [
+                       'sick-kid',
+               ],
+               x: 0.155,
+               y: 0.525,
+       },
+       {
+               id: 'uncle',
+               checks: [
+                       'uncle',
+                       'secret-passage',
+               ],
+               x: 0.6,
+               y: 0.4,
+       },
+       {
+               id: 'spec-rock',
+               checks: [
+                       'spec-rock',
+               ],
+               x: 0.48,
+               y: 0.09,
+       },
+       {
+               id: 'spec-rock-cave',
+               checks: [
+                       'spec-rock-cave',
+               ],
+               x: 0.48,
+               y: 0.14,
+       },
+       {
+               id: 'spiral-cave',
+               checks: [
+                       'spiral-cave',
+               ],
+               x: 0.8,
+               y: 0.1,
+       },
+       {
+               id: 'tavern',
+               checks: [
+                       'tavern',
+               ],
+               x: 0.16,
+               y: 0.58,
+       },
+       {
+               id: 'waterfall-fairy',
+               checks: [
+                       'waterfall-fairy-left',
+                       'waterfall-fairy-right',
+               ],
+               x: 0.9,
+               y: 0.15,
+       },
+       {
+               id: 'zora',
+               checks: [
+                       'zora',
+               ],
+               x: 0.975,
+               y: 0.12,
+       },
+       {
+               id: 'zora-ledge',
+               checks: [
+                       'zora-ledge',
+               ],
+               x: 0.975,
+               y: 0.165,
+       },
+];
+
+const LW_LOCATIONS = [
+       ...GENERIC_LW_LOCATIONS,
+       {
+               id: 'links-house',
+               checks: [
+                       'links-house',
+               ],
+               x: 0.55,
+               y: 0.6875,
+       },
+];
+
+const INVERTED_LW_LOCATIONS = GENERIC_LW_LOCATIONS;
+
+const GENERIC_DW_DUNGEONS = [
+       {
+               id: 'pd',
+               x: 0.95,
+               y: 0.42,
+       },
+       {
+               id: 'sp',
+               x: 0.4675,
+               y: 0.9375,
+       },
+       {
+               id: 'sw',
+               x: 0.05,
+               y: 0.05,
+       },
+       {
+               id: 'tt',
+               x: 0.125,
+               y: 0.475,
+       },
+       {
+               id: 'ip',
+               x: 0.7975,
+               y: 0.86,
+       },
+       {
+               id: 'mm',
+               x: 0.12,
+               y: 0.82,
+       },
+       {
+               id: 'tr',
+               x: 0.94,
+               y: 0.06,
+       },
+];
+
+const DW_DUNGEONS = [
+       ...GENERIC_DW_DUNGEONS,
+       {
+               id: 'gt',
+               x: 0.56,
+               y: 0.05,
+       },
+];
+
+const INVERTED_DW_DUNGEONS = [
+       ...GENERIC_DW_DUNGEONS,
+       {
+               id: 'ct',
+               x: 0.56,
+               y: 0.05,
+       },
+];
+
+const GENERIC_DW_LOCATIONS = [
+       {
+               id: 'blacksmith',
+               checks: [
+                       'blacksmith',
+               ],
+               x: 0.15,
+               y: 0.65,
+       },
+       {
+               id: 'brewery',
+               checks: [
+                       'brewery',
+               ],
+               x: 0.1,
+               y: 0.6,
+       },
+       {
+               id: 'bumper-cave',
+               checks: [
+                       'bumper-cave',
+               ],
+               x: 0.325,
+               y: 0.15,
+       },
+       {
+               id: 'c-house',
+               checks: [
+                       'c-house',
+               ],
+               x: 0.2,
+               y: 0.5,
+       },
+       {
+               id: 'catfish',
+               checks: [
+                       'catfish',
+               ],
+               x: 0.9,
+               y: 0.175,
+       },
+       {
+               id: 'chest-game',
+               checks: [
+                       'chest-game',
+               ],
+               x: 0.05,
+               y: 0.45,
+       },
+       {
+               id: 'digging-game',
+               checks: [
+                       'digging-game',
+               ],
+               x: 0.05,
+               y: 0.7,
+       },
+       {
+               id: 'hammer-pegs',
+               checks: [
+                       'hammer-pegs',
+               ],
+               x: 0.3125,
+               y: 0.6,
+       },
+       {
+               id: 'hookshot-cave',
+               checks: [
+                       'hookshot-cave-tl',
+                       'hookshot-cave-tr',
+                       'hookshot-cave-bl',
+               ],
+               x: 0.85,
+               y: 0.02,
+       },
+       {
+               id: 'hookshot-cave-bonk',
+               checks: [
+                       'hookshot-cave-br',
+               ],
+               x: 0.85,
+               y: 0.065,
+       },
+       {
+               id: 'hype-cave',
+               checks: [
+                       'hype-cave-top',
+                       'hype-cave-left',
+                       'hype-cave-right',
+                       'hype-cave-bottom',
+                       'hype-cave-npc',
+               ],
+               x: 0.6,
+               y: 0.75,
+       },
+       {
+               id: 'mire-shed',
+               checks: [
+                       'mire-shed-left',
+                       'mire-shed-right',
+               ],
+               x: 0.04,
+               y: 0.8,
+       },
+       {
+               id: 'purple-chest',
+               checks: [
+                       'purple-chest',
+               ],
+               x: 0.3125,
+               y: 0.525,
+       },
+       {
+               id: 'pyramid',
+               checks: [
+                       'pyramid',
+               ],
+               x: 0.575,
+               y: 0.45,
+       },
+       {
+               id: 'pyramid-fairy',
+               checks: [
+                       'pyramid-fairy-left',
+                       'pyramid-fairy-right',
+               ],
+               x: 0.45,
+               y: 0.5,
+       },
+       {
+               id: 'spike-cave',
+               checks: [
+                       'spike-cave',
+               ],
+               x: 0.575,
+               y: 0.15,
+       },
+       {
+               id: 'stumpy',
+               checks: [
+                       'stumpy',
+               ],
+               x: 0.3125,
+               y: 0.6875,
+       },
+       {
+               id: 'super-bunny',
+               checks: [
+                       'super-bunny-top',
+                       'super-bunny-bottom',
+               ],
+               x: 0.85,
+               y: 0.15,
+       },
+];
+
+const DW_LOCATIONS = GENERIC_DW_LOCATIONS;
+
+const INVERTED_DW_LOCATIONS = [
+       ...GENERIC_DW_LOCATIONS,
+       {
+               id: 'links-house',
+               checks: [
+                       'links-house',
+               ],
+               x: 0.55,
+               y: 0.6875,
+       },
+];
+
+const Location = ({ number, l, size }) => {
+       const { t } = useTranslation();
+
+       const classNames = ['location', `status-${l.status}`];
+       if (size) {
+               classNames.push(`size-${size}`);
+       }
+       if (l.handlePrimary) {
+               classNames.push('clickable');
+       }
+
+       return <g
+               className={classNames.join(' ')}
+               onClick={(e) => {
+                       l.handlePrimary();
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
+               onContextMenu={(e) => {
+                       l.handleSecondary();
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
+               transform={`translate(${l.x} ${l.y})`}
+       >
+               <title>{t(`tracker.location.${l.id}`)}</title>
+               <rect className="box" x="0" y="0" />
+               {number && l.remaining ?
+                       <text className="text" x="0" y="0">{l.remaining}</text>
+               : null}
+       </g>;
+};
+
+Location.propTypes = {
+       number: PropTypes.bool,
+       l: PropTypes.shape({
+               id: PropTypes.string,
+               x: PropTypes.number,
+               y: PropTypes.number,
+               number: PropTypes.number,
+               remaining: PropTypes.number,
+               status: PropTypes.string,
+               handlePrimary: PropTypes.func,
+               handleSecondary: PropTypes.func,
+       }),
+       size: PropTypes.string,
+};
+
+const makeBackground = (src, level) => {
+       const amount = Math.pow(2, Math.max(0, level - 8));
+       const size = 1 / amount;
+       const tiles = [];
+       for (let y = 0; y < amount; ++y) {
+               for (let x = 0; x < amount; ++x) {
+                       tiles.push(<image
+                               key={`${x}-${y}`}
+                               x={x * size}
+                               y={y * size}
+                               width={size * 1.002}
+                               height={size * 1.002}
+                               href={`/media/alttp/map/${src}/${level}/${x}_${y}.png`}
+                       />);
+               }
+       }
+       return tiles;
+};
+
+const Overworld = () => {
+       const { config, dungeons, logic, setManualState, state } = useTracker();
+
+       const mapDungeon = React.useCallback(dungeon => {
+               const definition = dungeons.find(d => d.id === dungeon.id);
+               const remaining = getDungeonRemainingItems(state, definition);
+               const status = aggregateDungeonStatus(definition, logic, state);
+               return {
+                       ...dungeon,
+                       status,
+                       remaining,
+                       handlePrimary: () => {
+                               if (getDungeonRemainingItems(state, definition)) {
+                                       setManualState(addDungeonCheck(definition));
+                               } else if (
+                                       !hasDungeonBoss(state, definition) || !hasDungeonPrize(state, definition)
+                               ) {
+                                       if (definition.boss) {
+                                               setManualState(setBossDefeated(definition, true));
+                                       }
+                                       if (definition.prize) {
+                                               setManualState(setPrizeAcquired(definition, true));
+                                       }
+                               } else {
+                                       setManualState(resetDungeonChecks(definition));
+                                       if (definition.boss) {
+                                               setManualState(setBossDefeated(definition, false));
+                                       }
+                                       if (definition.prize) {
+                                               setManualState(setPrizeAcquired(definition, false));
+                                       }
+                               }
+                       },
+                       handleSecondary: () => {
+                               if (isDungeonCleared(state, definition)) {
+                                       if (definition.items) {
+                                               setManualState(removeDungeonCheck(definition));
+                                       }
+                                       if (definition.boss) {
+                                               setManualState(setBossDefeated(definition, false));
+                                       }
+                                       if (definition.prize) {
+                                               setManualState(setPrizeAcquired(definition, false));
+                                       }
+                               } else if (getDungeonClearedItems(state, definition)) {
+                                       setManualState(removeDungeonCheck(definition));
+                               } else {
+                                       setManualState(completeDungeonChecks(definition));
+                                       if (definition.boss) {
+                                               setManualState(setBossDefeated(definition, true));
+                                       }
+                                       if (definition.prize) {
+                                               setManualState(setPrizeAcquired(definition, true));
+                                       }
+                               }
+                       },
+               };
+       }, [dungeons, logic, setManualState, state]);
+
+       const mapLocation = React.useCallback(loc => {
+               const remaining = countRemainingLocations(state, loc.checks);
+               const status = aggregateLocationStatus(loc.checks, logic, state);
+               return {
+                       ...loc,
+                       remaining,
+                       status,
+                       handlePrimary: () => {
+                               if (remaining) {
+                                       setManualState(clearAll(loc.checks));
+                               } else {
+                                       setManualState(unclearAll(loc.checks));
+                               }
+                       },
+                       handleSecondary: () => {
+                               if (remaining) {
+                                       setManualState(clearAll(loc.checks));
+                               } else {
+                                       setManualState(unclearAll(loc.checks));
+                               }
+                       },
+               };
+       }, [logic, setManualState, state]);
+
+       const lwDungeons = React.useMemo(() =>
+               (config.worldState === 'inverted' ? INVERTED_LW_DUNGEONS : LW_DUNGEONS)
+               .map(mapDungeon)
+       , [mapDungeon]);
+       const lwLocations = React.useMemo(() =>
+               (config.worldState === 'inverted' ? INVERTED_LW_LOCATIONS : LW_LOCATIONS)
+               .map(mapLocation)
+       , [mapLocation]);
+
+       const dwDungeons = React.useMemo(() =>
+               (config.worldState === 'inverted' ? INVERTED_DW_DUNGEONS : DW_DUNGEONS)
+               .map(mapDungeon)
+       , [mapDungeon]);
+       const dwLocations = React.useMemo(() =>
+               (config.worldState === 'inverted' ? INVERTED_DW_LOCATIONS : DW_LOCATIONS)
+               .map(mapLocation)
+       , [mapLocation]);
+
+       const layout = React.useMemo(() => {
+               if (config.mapLayout === 'vertical') {
+                       return {
+                               lwTransform: '',
+                               dwTransform: 'translate(0 1)',
+                       };
+               } else {
+                       return {
+                               lwTransform: 'scale(0.5)',
+                               dwTransform: 'scale(0.5) translate(1 0)',
+                       };
+               }
+       }, [config]);
+
+       return <>
+               <g className="light-world" transform={layout.lwTransform}>
+                       <g className="background">
+                               {makeBackground('lw_files', 10)}
+                       </g>
+                       <g className="locations">
+                               {lwLocations.map(l =>
+                                       <Location key={l.id} l={l} />
+                               )}
+                               {lwDungeons.map(l =>
+                                       <Location key={l.id} number l={l} size="lg" />
+                               )}
+                       </g>
+               </g>
+               <g className="dark-world" transform={layout.dwTransform}>
+                       <g className="background">
+                               {makeBackground('dw_files', 10)}
+                       </g>
+                       <g className="locations">
+                               {dwLocations.map(l =>
+                                       <Location key={l.id} l={l} />
+                               )}
+                               {dwDungeons.map(l =>
+                                       <Location key={l.id} number l={l} size="lg" />
+                               )}
+                       </g>
+               </g>
+       </>;
+};
+
+export default Overworld;
diff --git a/resources/js/components/tracker/Map/index.js b/resources/js/components/tracker/Map/index.js
deleted file mode 100644 (file)
index 246403c..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-
-import Overworld from './Overworld';
-
-const Map = () => {
-       return <>
-               <Overworld />
-       </>;
-};
-
-export default Map;
diff --git a/resources/js/components/tracker/Map/index.jsx b/resources/js/components/tracker/Map/index.jsx
new file mode 100644 (file)
index 0000000..246403c
--- /dev/null
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import Overworld from './Overworld';
+
+const Map = () => {
+       return <>
+               <Overworld />
+       </>;
+};
+
+export default Map;
diff --git a/resources/js/components/tracker/ToggleIcon.js b/resources/js/components/tracker/ToggleIcon.js
deleted file mode 100644 (file)
index d3e3960..0000000
+++ /dev/null
@@ -1,312 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import ZeldaIcon from '../common/ZeldaIcon';
-import {
-       addDungeonCheck,
-       decrement,
-       getDungeonBoss,
-       getDungeonRemainingItems,
-       getDungeonPrize,
-       getGTBoss,
-       hasDungeonBoss,
-       hasDungeonPrize,
-       highestActive,
-       increment,
-       removeDungeonCheck,
-       toggleBoolean,
-       toggleBossDefeated,
-} from '../../helpers/tracker';
-import { useTracker } from '../../hooks/tracker';
-
-const ToggleIcon = ({ controller, className, icons, svg, title, transform }) => {
-       const { setManualState, state } = useTracker();
-       const activeController = controller || ToggleIcon.nullController;
-       const active = activeController.getActive(state, icons);
-       const defaultIcon = activeController.getDefault(state, icons);
-       const icon = active || defaultIcon || icons[0];
-       const classNames = ['toggle-icon'];
-       if (active) {
-               classNames.push('active');
-       } else {
-               classNames.push('inactive');
-       }
-       if (className) {
-               classNames.push(className);
-       }
-       if (svg) {
-               return <g
-                       className={classNames.join(' ')}
-                       data-icon={icon}
-                       onClick={(e) => {
-                               activeController.handlePrimary(state, setManualState, icons);
-                               e.preventDefault();
-                               e.stopPropagation();
-                       }}
-                       onContextMenu={(e) => {
-                               activeController.handleSecondary(state, setManualState, icons);
-                               e.preventDefault();
-                               e.stopPropagation();
-                       }}
-                       transform={transform}
-               >
-                       <ZeldaIcon name={icon} svg title={title} />
-               </g>;
-       }
-       return <span
-               className={classNames.join(' ')}
-               onClick={(e) => {
-                       activeController.handlePrimary(state, setManualState, icons);
-                       e.preventDefault();
-                       e.stopPropagation();
-               }}
-               onContextMenu={(e) => {
-                       activeController.handleSecondary(state, setManualState, icons);
-                       e.preventDefault();
-                       e.stopPropagation();
-               }}
-       >
-               <ZeldaIcon name={active || defaultIcon || icons[0]} title={title} />
-       </span>;
-};
-
-const doNothing = () => { };
-
-const firstIcon = (state, icons) => icons[0];
-
-const nextIcon = (state, setState, icons) => {
-       const highest = highestActive(state, icons);
-       const highestIndex = highest ? icons.indexOf(highest) : -1;
-       if (highestIndex + 1 < icons.length) {
-               setState(toggleBoolean(icons[highestIndex + 1]));
-       } else {
-               const changes = {};
-               icons.forEach(icon => {
-                       changes[icon] = false;
-               });
-               setState(s => ({ ...s, ...changes }));
-       }
-};
-
-const previousIcon = (state, setState, icons) => {
-       const highest = highestActive(state, icons);
-       const highestIndex = highest ? icons.indexOf(highest) : -1;
-       if (highestIndex >= 0) {
-               setState(toggleBoolean(icons[highestIndex]));
-       } else {
-               const changes = {};
-               icons.forEach(icon => {
-                       changes[icon] = true;
-               });
-               setState(s => ({ ...s, ...changes }));
-       }
-};
-
-const nextString = property => (state, setState, icons) => {
-       const current = state[property] || icons[0];
-       const currentIndex = icons.indexOf(current);
-       const nextIndex = (currentIndex + 1) % icons.length;
-       const next = icons[nextIndex];
-       setState(s => ({ ...s, [property]: next }));
-};
-
-const previousString = property => (state, setState, icons) => {
-       const current = state[property] || icons[0];
-       const currentIndex = icons.indexOf(current);
-       const previousIndex = (currentIndex + icons.length - 1) % icons.length;
-       const previous = icons[previousIndex];
-       setState(s => ({ ...s, [property]: previous }));
-};
-
-ToggleIcon.bottleController = ctrl => ({
-       getActive: (state, icons) => state[ctrl] ? icons[state[ctrl] - 1] : null,
-       getDefault: () => 'bottle',
-       handlePrimary: (state, setState, icons) => {
-               if (state[ctrl] === 0) {
-                       // skip over mushroom
-                       setState(s => ({ ...s, [ctrl]: 2 }));
-               } else {
-                       setState(increment(ctrl, icons.length));
-               }
-       },
-       handleSecondary: (state, setState, icons) => {
-               if (state[ctrl] === 2) {
-                       // skip over mushroom
-                       setState(s => ({ ...s, [ctrl]: 0 }));
-               } else {
-                       setState(decrement(ctrl, icons.length));
-               }
-       },
-});
-
-ToggleIcon.countController = max => ({
-       getActive: highestActive,
-       getDefault: firstIcon,
-       handlePrimary: (state, setState, icons) => {
-               setState(increment(icons[0], max));
-       },
-       handleSecondary: (state, setState, icons) => {
-               setState(decrement(icons[0], max));
-       },
-});
-
-ToggleIcon.dungeonBossController = (dungeon) => ({
-       getActive: (state) => hasDungeonBoss(state, dungeon) ? getDungeonBoss(state, dungeon) : null,
-       getDefault: (state) => getDungeonBoss(state, dungeon),
-       handlePrimary: dungeon.bosses.length > 1
-               ? nextString(`${dungeon.id}-boss`)
-               : (state, setState) => {
-                       setState(toggleBossDefeated(dungeon));
-               },
-       handleSecondary: dungeon.bosses.length > 1 ?
-               previousString(`${dungeon.id}-boss`)
-               : (state, setState) => {
-                       setState(toggleBossDefeated(dungeon));
-               },
-});
-
-ToggleIcon.dungeonCheckController = (dungeon) => ({
-       getActive: (state, icons) => getDungeonRemainingItems(state, dungeon) ? icons[1] : null,
-       getDefault: firstIcon,
-       handlePrimary: (state, setState) => {
-               setState(addDungeonCheck(dungeon));
-       },
-       handleSecondary: (state, setState) => {
-               setState(removeDungeonCheck(dungeon));
-       },
-});
-
-ToggleIcon.dungeonController = dungeon => ({
-       getActive: (state, icons) => state[`${dungeon.id}-${icons[0]}`] ? icons[0] : null,
-       getDefault: firstIcon,
-       handlePrimary: (state, setState, icons) => {
-               setState(toggleBoolean(`${dungeon.id}-${icons[0]}`));
-       },
-       handleSecondary: (state, setState, icons) => {
-               setState(toggleBoolean(`${dungeon.id}-${icons[0]}`));
-       },
-});
-
-ToggleIcon.dungeonCountController = (dungeon, max) => ({
-       getActive: (state, icons) => state[`${dungeon.id}-${icons[0]}`] ? icons[0] : null,
-       getDefault: firstIcon,
-       handlePrimary: (state, setState, icons) => {
-               setState(increment(`${dungeon.id}-${icons[0]}`, max));
-       },
-       handleSecondary: (state, setState, icons) => {
-               setState(decrement(`${dungeon.id}-${icons[0]}`, max));
-       },
-});
-
-ToggleIcon.dungeonPrizeController = (dungeon) => ({
-       getActive: (state) => hasDungeonPrize(state, dungeon) ? getDungeonPrize(state, dungeon) : null,
-       getDefault: (state) => getDungeonPrize(state, dungeon),
-       handlePrimary: nextString(`${dungeon.id}-prize`),
-       handleSecondary: previousString(`${dungeon.id}-prize`),
-});
-
-ToggleIcon.gtBossController = (which) => ({
-       getActive: (state) => getGTBoss(state, which),
-       getDefault: (state) => getGTBoss(state, which),
-       handlePrimary: nextString(`gt-${which}-boss`),
-       handleSecondary: previousString(`gt-${which}-boss`),
-});
-
-ToggleIcon.medallionController = {
-       getActive: highestActive,
-       getDefault: firstIcon,
-       handlePrimary: nextIcon,
-       handleSecondary: (state, setState, icons) => {
-               const mm = state['mm-medallion'];
-               const tr = state['tr-medallion'];
-               const isMM = mm === icons[0];
-               const isTR = tr === icons[0];
-               console.log({ mm, isMM, tr, isTR });
-               if (!isMM && !isTR) {
-                       // empty: set as MM if mire is unset, else set as TR if TR is unset
-                       if (!mm) {
-                               setState(s => ({ ...s, 'mm-medallion': icons[0] }));
-                       } else if (!tr) {
-                               setState(s => ({ ...s, 'tr-medallion': icons[0] }));
-                       }
-               } else if (isMM && !isTR) {
-                       // MM: if TR is free, switch to TR, otherwise remove MM
-                       if (!tr) {
-                               setState(s => ({ ...s, 'mm-medallion': null, 'tr-medallion': icons[0] }));
-                       } else {
-                               setState(s => ({ ...s, 'mm-medallion': null }));
-                       }
-               } else if (!isMM && isTR) {
-                       // TR: if MM is free, switch to both, otherwise remove TR
-                       if (!mm) {
-                               setState(s => ({ ...s, 'mm-medallion': icons[0] }));
-                       } else {
-                               setState(s => ({ ...s, 'tr-medallion': null }));
-                       }
-               } else {
-                       // both: remove both
-                       setState(s => ({ ...s, 'mm-medallion': null, 'tr-medallion': null }));
-               }
-       },
-};
-
-ToggleIcon.modulusController = ctrl => ({
-       getActive: (state, icons) => icons[(state[ctrl] || 0) % icons.length],
-       getDefault: firstIcon,
-       handlePrimary: (state, setState, icons) => {
-               setState(increment(ctrl, icons.length));
-       },
-       handleSecondary: (state, setState, icons) => {
-               setState(decrement(ctrl, icons.length));
-       },
-});
-
-ToggleIcon.nullController = {
-       getActive: () => null,
-       getDefault: firstIcon,
-       handlePrimary: doNothing,
-       handleSecondary: doNothing,
-};
-
-ToggleIcon.pinController = (pin, removePin) => ({
-       getActive: firstIcon,
-       getDefault: firstIcon,
-       handlePrimary: doNothing,
-       handleSecondary: () => removePin(pin),
-});
-
-ToggleIcon.simpleController = {
-       getActive: highestActive,
-       getDefault: firstIcon,
-       handlePrimary: nextIcon,
-       handleSecondary: previousIcon,
-};
-
-ToggleIcon.progressiveController = (master, min, max) => ({
-       getActive: (state, icons) => {
-               const count = Math.max(min, Math.min(max, state[master] || 0));
-               return count ? icons[count - 1] : null;
-       },
-       getDefault: firstIcon,
-       handlePrimary: (state, setState) => {
-               setState(increment(master, max, min));
-       },
-       handleSecondary: (state, setState) => {
-               setState(decrement(master, max, min));
-       },
-});
-
-ToggleIcon.propTypes = {
-       active: PropTypes.string,
-       className: PropTypes.string,
-       controller: PropTypes.shape({
-               handlePrimary: PropTypes.func,
-               handleSecondary: PropTypes.func,
-       }),
-       icons: PropTypes.arrayOf(PropTypes.string),
-       svg: PropTypes.bool,
-       title: PropTypes.string,
-       transform: PropTypes.string,
-};
-
-export default ToggleIcon;
diff --git a/resources/js/components/tracker/ToggleIcon.jsx b/resources/js/components/tracker/ToggleIcon.jsx
new file mode 100644 (file)
index 0000000..d3e3960
--- /dev/null
@@ -0,0 +1,312 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import ZeldaIcon from '../common/ZeldaIcon';
+import {
+       addDungeonCheck,
+       decrement,
+       getDungeonBoss,
+       getDungeonRemainingItems,
+       getDungeonPrize,
+       getGTBoss,
+       hasDungeonBoss,
+       hasDungeonPrize,
+       highestActive,
+       increment,
+       removeDungeonCheck,
+       toggleBoolean,
+       toggleBossDefeated,
+} from '../../helpers/tracker';
+import { useTracker } from '../../hooks/tracker';
+
+const ToggleIcon = ({ controller, className, icons, svg, title, transform }) => {
+       const { setManualState, state } = useTracker();
+       const activeController = controller || ToggleIcon.nullController;
+       const active = activeController.getActive(state, icons);
+       const defaultIcon = activeController.getDefault(state, icons);
+       const icon = active || defaultIcon || icons[0];
+       const classNames = ['toggle-icon'];
+       if (active) {
+               classNames.push('active');
+       } else {
+               classNames.push('inactive');
+       }
+       if (className) {
+               classNames.push(className);
+       }
+       if (svg) {
+               return <g
+                       className={classNames.join(' ')}
+                       data-icon={icon}
+                       onClick={(e) => {
+                               activeController.handlePrimary(state, setManualState, icons);
+                               e.preventDefault();
+                               e.stopPropagation();
+                       }}
+                       onContextMenu={(e) => {
+                               activeController.handleSecondary(state, setManualState, icons);
+                               e.preventDefault();
+                               e.stopPropagation();
+                       }}
+                       transform={transform}
+               >
+                       <ZeldaIcon name={icon} svg title={title} />
+               </g>;
+       }
+       return <span
+               className={classNames.join(' ')}
+               onClick={(e) => {
+                       activeController.handlePrimary(state, setManualState, icons);
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
+               onContextMenu={(e) => {
+                       activeController.handleSecondary(state, setManualState, icons);
+                       e.preventDefault();
+                       e.stopPropagation();
+               }}
+       >
+               <ZeldaIcon name={active || defaultIcon || icons[0]} title={title} />
+       </span>;
+};
+
+const doNothing = () => { };
+
+const firstIcon = (state, icons) => icons[0];
+
+const nextIcon = (state, setState, icons) => {
+       const highest = highestActive(state, icons);
+       const highestIndex = highest ? icons.indexOf(highest) : -1;
+       if (highestIndex + 1 < icons.length) {
+               setState(toggleBoolean(icons[highestIndex + 1]));
+       } else {
+               const changes = {};
+               icons.forEach(icon => {
+                       changes[icon] = false;
+               });
+               setState(s => ({ ...s, ...changes }));
+       }
+};
+
+const previousIcon = (state, setState, icons) => {
+       const highest = highestActive(state, icons);
+       const highestIndex = highest ? icons.indexOf(highest) : -1;
+       if (highestIndex >= 0) {
+               setState(toggleBoolean(icons[highestIndex]));
+       } else {
+               const changes = {};
+               icons.forEach(icon => {
+                       changes[icon] = true;
+               });
+               setState(s => ({ ...s, ...changes }));
+       }
+};
+
+const nextString = property => (state, setState, icons) => {
+       const current = state[property] || icons[0];
+       const currentIndex = icons.indexOf(current);
+       const nextIndex = (currentIndex + 1) % icons.length;
+       const next = icons[nextIndex];
+       setState(s => ({ ...s, [property]: next }));
+};
+
+const previousString = property => (state, setState, icons) => {
+       const current = state[property] || icons[0];
+       const currentIndex = icons.indexOf(current);
+       const previousIndex = (currentIndex + icons.length - 1) % icons.length;
+       const previous = icons[previousIndex];
+       setState(s => ({ ...s, [property]: previous }));
+};
+
+ToggleIcon.bottleController = ctrl => ({
+       getActive: (state, icons) => state[ctrl] ? icons[state[ctrl] - 1] : null,
+       getDefault: () => 'bottle',
+       handlePrimary: (state, setState, icons) => {
+               if (state[ctrl] === 0) {
+                       // skip over mushroom
+                       setState(s => ({ ...s, [ctrl]: 2 }));
+               } else {
+                       setState(increment(ctrl, icons.length));
+               }
+       },
+       handleSecondary: (state, setState, icons) => {
+               if (state[ctrl] === 2) {
+                       // skip over mushroom
+                       setState(s => ({ ...s, [ctrl]: 0 }));
+               } else {
+                       setState(decrement(ctrl, icons.length));
+               }
+       },
+});
+
+ToggleIcon.countController = max => ({
+       getActive: highestActive,
+       getDefault: firstIcon,
+       handlePrimary: (state, setState, icons) => {
+               setState(increment(icons[0], max));
+       },
+       handleSecondary: (state, setState, icons) => {
+               setState(decrement(icons[0], max));
+       },
+});
+
+ToggleIcon.dungeonBossController = (dungeon) => ({
+       getActive: (state) => hasDungeonBoss(state, dungeon) ? getDungeonBoss(state, dungeon) : null,
+       getDefault: (state) => getDungeonBoss(state, dungeon),
+       handlePrimary: dungeon.bosses.length > 1
+               ? nextString(`${dungeon.id}-boss`)
+               : (state, setState) => {
+                       setState(toggleBossDefeated(dungeon));
+               },
+       handleSecondary: dungeon.bosses.length > 1 ?
+               previousString(`${dungeon.id}-boss`)
+               : (state, setState) => {
+                       setState(toggleBossDefeated(dungeon));
+               },
+});
+
+ToggleIcon.dungeonCheckController = (dungeon) => ({
+       getActive: (state, icons) => getDungeonRemainingItems(state, dungeon) ? icons[1] : null,
+       getDefault: firstIcon,
+       handlePrimary: (state, setState) => {
+               setState(addDungeonCheck(dungeon));
+       },
+       handleSecondary: (state, setState) => {
+               setState(removeDungeonCheck(dungeon));
+       },
+});
+
+ToggleIcon.dungeonController = dungeon => ({
+       getActive: (state, icons) => state[`${dungeon.id}-${icons[0]}`] ? icons[0] : null,
+       getDefault: firstIcon,
+       handlePrimary: (state, setState, icons) => {
+               setState(toggleBoolean(`${dungeon.id}-${icons[0]}`));
+       },
+       handleSecondary: (state, setState, icons) => {
+               setState(toggleBoolean(`${dungeon.id}-${icons[0]}`));
+       },
+});
+
+ToggleIcon.dungeonCountController = (dungeon, max) => ({
+       getActive: (state, icons) => state[`${dungeon.id}-${icons[0]}`] ? icons[0] : null,
+       getDefault: firstIcon,
+       handlePrimary: (state, setState, icons) => {
+               setState(increment(`${dungeon.id}-${icons[0]}`, max));
+       },
+       handleSecondary: (state, setState, icons) => {
+               setState(decrement(`${dungeon.id}-${icons[0]}`, max));
+       },
+});
+
+ToggleIcon.dungeonPrizeController = (dungeon) => ({
+       getActive: (state) => hasDungeonPrize(state, dungeon) ? getDungeonPrize(state, dungeon) : null,
+       getDefault: (state) => getDungeonPrize(state, dungeon),
+       handlePrimary: nextString(`${dungeon.id}-prize`),
+       handleSecondary: previousString(`${dungeon.id}-prize`),
+});
+
+ToggleIcon.gtBossController = (which) => ({
+       getActive: (state) => getGTBoss(state, which),
+       getDefault: (state) => getGTBoss(state, which),
+       handlePrimary: nextString(`gt-${which}-boss`),
+       handleSecondary: previousString(`gt-${which}-boss`),
+});
+
+ToggleIcon.medallionController = {
+       getActive: highestActive,
+       getDefault: firstIcon,
+       handlePrimary: nextIcon,
+       handleSecondary: (state, setState, icons) => {
+               const mm = state['mm-medallion'];
+               const tr = state['tr-medallion'];
+               const isMM = mm === icons[0];
+               const isTR = tr === icons[0];
+               console.log({ mm, isMM, tr, isTR });
+               if (!isMM && !isTR) {
+                       // empty: set as MM if mire is unset, else set as TR if TR is unset
+                       if (!mm) {
+                               setState(s => ({ ...s, 'mm-medallion': icons[0] }));
+                       } else if (!tr) {
+                               setState(s => ({ ...s, 'tr-medallion': icons[0] }));
+                       }
+               } else if (isMM && !isTR) {
+                       // MM: if TR is free, switch to TR, otherwise remove MM
+                       if (!tr) {
+                               setState(s => ({ ...s, 'mm-medallion': null, 'tr-medallion': icons[0] }));
+                       } else {
+                               setState(s => ({ ...s, 'mm-medallion': null }));
+                       }
+               } else if (!isMM && isTR) {
+                       // TR: if MM is free, switch to both, otherwise remove TR
+                       if (!mm) {
+                               setState(s => ({ ...s, 'mm-medallion': icons[0] }));
+                       } else {
+                               setState(s => ({ ...s, 'tr-medallion': null }));
+                       }
+               } else {
+                       // both: remove both
+                       setState(s => ({ ...s, 'mm-medallion': null, 'tr-medallion': null }));
+               }
+       },
+};
+
+ToggleIcon.modulusController = ctrl => ({
+       getActive: (state, icons) => icons[(state[ctrl] || 0) % icons.length],
+       getDefault: firstIcon,
+       handlePrimary: (state, setState, icons) => {
+               setState(increment(ctrl, icons.length));
+       },
+       handleSecondary: (state, setState, icons) => {
+               setState(decrement(ctrl, icons.length));
+       },
+});
+
+ToggleIcon.nullController = {
+       getActive: () => null,
+       getDefault: firstIcon,
+       handlePrimary: doNothing,
+       handleSecondary: doNothing,
+};
+
+ToggleIcon.pinController = (pin, removePin) => ({
+       getActive: firstIcon,
+       getDefault: firstIcon,
+       handlePrimary: doNothing,
+       handleSecondary: () => removePin(pin),
+});
+
+ToggleIcon.simpleController = {
+       getActive: highestActive,
+       getDefault: firstIcon,
+       handlePrimary: nextIcon,
+       handleSecondary: previousIcon,
+};
+
+ToggleIcon.progressiveController = (master, min, max) => ({
+       getActive: (state, icons) => {
+               const count = Math.max(min, Math.min(max, state[master] || 0));
+               return count ? icons[count - 1] : null;
+       },
+       getDefault: firstIcon,
+       handlePrimary: (state, setState) => {
+               setState(increment(master, max, min));
+       },
+       handleSecondary: (state, setState) => {
+               setState(decrement(master, max, min));
+       },
+});
+
+ToggleIcon.propTypes = {
+       active: PropTypes.string,
+       className: PropTypes.string,
+       controller: PropTypes.shape({
+               handlePrimary: PropTypes.func,
+               handleSecondary: PropTypes.func,
+       }),
+       icons: PropTypes.arrayOf(PropTypes.string),
+       svg: PropTypes.bool,
+       title: PropTypes.string,
+       transform: PropTypes.string,
+};
+
+export default ToggleIcon;
diff --git a/resources/js/components/tracker/Toolbar.js b/resources/js/components/tracker/Toolbar.js
deleted file mode 100644 (file)
index f9d488a..0000000
+++ /dev/null
@@ -1,170 +0,0 @@
-import React from 'react';
-import { Button, Container, Form, Navbar } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import AutoTracking from './AutoTracking';
-import ConfigDialog from './ConfigDialog';
-import ToggleIcon from './ToggleIcon';
-import Icon from '../common/Icon';
-import ZeldaIcon from '../common/ZeldaIcon';
-import { getConfigValue } from '../../helpers/tracker';
-import { useTracker } from '../../hooks/tracker';
-
-const mapWild = {
-       map: 'wildMap',
-       compass: 'wildCompass',
-       'small-key': 'wildSmall',
-       'big-key': 'wildBig',
-};
-
-const Toolbar = () => {
-       const [showConfigDialog, setShowConfigDialog] = React.useState(false);
-       const { config, saveConfig } = useTracker();
-       const { t } = useTranslation();
-
-       const handleConfigChange = React.useCallback(({ target: { name, value } }) => {
-               saveConfig({ [name]: value });
-       }, [saveConfig]);
-
-       const bossController = React.useMemo(() => ({
-               getActive: (state, icons) => config.bossShuffle ? icons[0] : null,
-               getDefault: (state, icons) => icons[0],
-               handlePrimary: () => {
-                       saveConfig({ bossShuffle: !config.bossShuffle});
-               },
-               handleSecondary: () => null,
-       }), [config, saveConfig]);
-
-       const wildController = React.useMemo(() => ({
-               getActive: (state, icons) => config[mapWild[icons[0]]] ? icons[0] : null,
-               getDefault: (state, icons) => icons[0],
-               handlePrimary: (state, setState, icons) => {
-                       const prop = mapWild[icons[0]];
-                       saveConfig({ [prop]: !config[prop] });
-               },
-               handleSecondary: () => null,
-       }), [config, saveConfig]);
-
-       const worldController = React.useMemo(() => ({
-               getActive: (state, icons) => config.worldState === 'inverted' ? icons[1] : icons[0],
-               getDefault: (state, icons) => icons[0],
-               handlePrimary: () => {
-                       saveConfig({ worldState: config.worldState == 'inverted' ? 'open' : 'inverted' });
-               },
-               handleSecondary: () => null,
-       }), [config, saveConfig]);
-
-       return <Navbar bg="dark" className="tracker-toolbar" variant="dark">
-               <Container fluid>
-                       <div className="button-bar">
-                               <Button
-                                       className="me-3"
-                                       onClick={() => setShowConfigDialog(true)}
-                                       title={t('button.settings')}
-                                       variant="outline-secondary"
-                               >
-                                       <Icon.SETTINGS title="" />
-                               </Button>
-                               <ToggleIcon
-                                       controller={wildController}
-                                       icons={['map']}
-                                       title={t('tracker.config.shuffleMap')}
-                               />
-                               <ToggleIcon
-                                       controller={wildController}
-                                       icons={['compass']}
-                                       title={t('tracker.config.shuffleCompass')}
-                               />
-                               <ToggleIcon
-                                       controller={wildController}
-                                       icons={['small-key']}
-                                       title={t('tracker.config.shuffleSmall')}
-                               />
-                               <ToggleIcon
-                                       controller={wildController}
-                                       icons={['big-key']}
-                                       title={t('tracker.config.shuffleBig')}
-                               />
-                               <ToggleIcon
-                                       className="ms-3"
-                                       controller={bossController}
-                                       icons={['armos']}
-                                       title={t('tracker.config.bossShuffle')}
-                               />
-                               <ToggleIcon
-                                       controller={worldController}
-                                       icons={['link-head', 'bunny-head']}
-                                       title={t('tracker.config.inverted')}
-                               />
-                       </div>
-                       <div>
-                               <Form.Group
-                                       className="d-inline-flex align-items-center justify-content-between"
-                                       controlId="tracker.gtCrystals"
-                               >
-                                       <Form.Label className="me-1">
-                                               <ZeldaIcon name="gt" title={t('tracker.config.gtCrystals')} />
-                                       </Form.Label>
-                                       <Form.Select
-                                               className="w-auto bg-dark"
-                                               name="gt-crystals"
-                                               onChange={handleConfigChange}
-                                               value={getConfigValue(config, 'gt-crystals', 7)}
-                                       >
-                                               {['?', 0, 1, 2, 3, 4, 5, 6, 7].map(n =>
-                                                       <option key={n} value={n}>
-                                                               {n}
-                                                       </option>
-                                               )}
-                                       </Form.Select>
-                               </Form.Group>
-                               <Form.Group
-                                       className="d-inline-flex align-items-center justify-content-between"
-                                       controlId="tracker.ganonCrystals"
-                               >
-                                       <Form.Label className="me-1">
-                                               <ZeldaIcon name="ganon" title={t('tracker.config.ganonCrystals')} />
-                                       </Form.Label>
-                                       <Form.Select
-                                               className="w-auto bg-dark"
-                                               name="ganon-crystals"
-                                               onChange={handleConfigChange}
-                                               value={getConfigValue(config, 'ganon-crystals', 7)}
-                                       >
-                                               {['?', 0, 1, 2, 3, 4, 5, 6, 7].map(n =>
-                                                       <option key={n} value={n}>
-                                                               {n}
-                                                       </option>
-                                               )}
-                                       </Form.Select>
-                               </Form.Group>
-                               <Form.Group
-                                       className="d-inline-flex align-items-center justify-content-between"
-                                       controlId="tracker.goal"
-                               >
-                                       <Form.Label className="me-1">
-                                               <ZeldaIcon name="triforce" title={t('tracker.config.goal')} />
-                                       </Form.Label>
-                                       <Form.Select
-                                               className="w-auto bg-dark"
-                                               name="goal"
-                                               onChange={handleConfigChange}
-                                               value={getConfigValue(config, 'goal', 'ganon')}
-                                       >
-                                               {['ganon', 'fast', 'ad', 'ped', 'trinity', 'thunt', 'ghunt'].map(n =>
-                                                       <option key={n} value={n}>
-                                                               {t(`tracker.config.goals.${n}`)}
-                                                       </option>
-                                               )}
-                                       </Form.Select>
-                               </Form.Group>
-                       </div>
-                       <div>
-                               <AutoTracking />
-                       </div>
-               </Container>
-               <ConfigDialog onHide={() => setShowConfigDialog(false)} show={showConfigDialog} />
-       </Navbar>;
-};
-
-export default Toolbar;
diff --git a/resources/js/components/tracker/Toolbar.jsx b/resources/js/components/tracker/Toolbar.jsx
new file mode 100644 (file)
index 0000000..f9d488a
--- /dev/null
@@ -0,0 +1,170 @@
+import React from 'react';
+import { Button, Container, Form, Navbar } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import AutoTracking from './AutoTracking';
+import ConfigDialog from './ConfigDialog';
+import ToggleIcon from './ToggleIcon';
+import Icon from '../common/Icon';
+import ZeldaIcon from '../common/ZeldaIcon';
+import { getConfigValue } from '../../helpers/tracker';
+import { useTracker } from '../../hooks/tracker';
+
+const mapWild = {
+       map: 'wildMap',
+       compass: 'wildCompass',
+       'small-key': 'wildSmall',
+       'big-key': 'wildBig',
+};
+
+const Toolbar = () => {
+       const [showConfigDialog, setShowConfigDialog] = React.useState(false);
+       const { config, saveConfig } = useTracker();
+       const { t } = useTranslation();
+
+       const handleConfigChange = React.useCallback(({ target: { name, value } }) => {
+               saveConfig({ [name]: value });
+       }, [saveConfig]);
+
+       const bossController = React.useMemo(() => ({
+               getActive: (state, icons) => config.bossShuffle ? icons[0] : null,
+               getDefault: (state, icons) => icons[0],
+               handlePrimary: () => {
+                       saveConfig({ bossShuffle: !config.bossShuffle});
+               },
+               handleSecondary: () => null,
+       }), [config, saveConfig]);
+
+       const wildController = React.useMemo(() => ({
+               getActive: (state, icons) => config[mapWild[icons[0]]] ? icons[0] : null,
+               getDefault: (state, icons) => icons[0],
+               handlePrimary: (state, setState, icons) => {
+                       const prop = mapWild[icons[0]];
+                       saveConfig({ [prop]: !config[prop] });
+               },
+               handleSecondary: () => null,
+       }), [config, saveConfig]);
+
+       const worldController = React.useMemo(() => ({
+               getActive: (state, icons) => config.worldState === 'inverted' ? icons[1] : icons[0],
+               getDefault: (state, icons) => icons[0],
+               handlePrimary: () => {
+                       saveConfig({ worldState: config.worldState == 'inverted' ? 'open' : 'inverted' });
+               },
+               handleSecondary: () => null,
+       }), [config, saveConfig]);
+
+       return <Navbar bg="dark" className="tracker-toolbar" variant="dark">
+               <Container fluid>
+                       <div className="button-bar">
+                               <Button
+                                       className="me-3"
+                                       onClick={() => setShowConfigDialog(true)}
+                                       title={t('button.settings')}
+                                       variant="outline-secondary"
+                               >
+                                       <Icon.SETTINGS title="" />
+                               </Button>
+                               <ToggleIcon
+                                       controller={wildController}
+                                       icons={['map']}
+                                       title={t('tracker.config.shuffleMap')}
+                               />
+                               <ToggleIcon
+                                       controller={wildController}
+                                       icons={['compass']}
+                                       title={t('tracker.config.shuffleCompass')}
+                               />
+                               <ToggleIcon
+                                       controller={wildController}
+                                       icons={['small-key']}
+                                       title={t('tracker.config.shuffleSmall')}
+                               />
+                               <ToggleIcon
+                                       controller={wildController}
+                                       icons={['big-key']}
+                                       title={t('tracker.config.shuffleBig')}
+                               />
+                               <ToggleIcon
+                                       className="ms-3"
+                                       controller={bossController}
+                                       icons={['armos']}
+                                       title={t('tracker.config.bossShuffle')}
+                               />
+                               <ToggleIcon
+                                       controller={worldController}
+                                       icons={['link-head', 'bunny-head']}
+                                       title={t('tracker.config.inverted')}
+                               />
+                       </div>
+                       <div>
+                               <Form.Group
+                                       className="d-inline-flex align-items-center justify-content-between"
+                                       controlId="tracker.gtCrystals"
+                               >
+                                       <Form.Label className="me-1">
+                                               <ZeldaIcon name="gt" title={t('tracker.config.gtCrystals')} />
+                                       </Form.Label>
+                                       <Form.Select
+                                               className="w-auto bg-dark"
+                                               name="gt-crystals"
+                                               onChange={handleConfigChange}
+                                               value={getConfigValue(config, 'gt-crystals', 7)}
+                                       >
+                                               {['?', 0, 1, 2, 3, 4, 5, 6, 7].map(n =>
+                                                       <option key={n} value={n}>
+                                                               {n}
+                                                       </option>
+                                               )}
+                                       </Form.Select>
+                               </Form.Group>
+                               <Form.Group
+                                       className="d-inline-flex align-items-center justify-content-between"
+                                       controlId="tracker.ganonCrystals"
+                               >
+                                       <Form.Label className="me-1">
+                                               <ZeldaIcon name="ganon" title={t('tracker.config.ganonCrystals')} />
+                                       </Form.Label>
+                                       <Form.Select
+                                               className="w-auto bg-dark"
+                                               name="ganon-crystals"
+                                               onChange={handleConfigChange}
+                                               value={getConfigValue(config, 'ganon-crystals', 7)}
+                                       >
+                                               {['?', 0, 1, 2, 3, 4, 5, 6, 7].map(n =>
+                                                       <option key={n} value={n}>
+                                                               {n}
+                                                       </option>
+                                               )}
+                                       </Form.Select>
+                               </Form.Group>
+                               <Form.Group
+                                       className="d-inline-flex align-items-center justify-content-between"
+                                       controlId="tracker.goal"
+                               >
+                                       <Form.Label className="me-1">
+                                               <ZeldaIcon name="triforce" title={t('tracker.config.goal')} />
+                                       </Form.Label>
+                                       <Form.Select
+                                               className="w-auto bg-dark"
+                                               name="goal"
+                                               onChange={handleConfigChange}
+                                               value={getConfigValue(config, 'goal', 'ganon')}
+                                       >
+                                               {['ganon', 'fast', 'ad', 'ped', 'trinity', 'thunt', 'ghunt'].map(n =>
+                                                       <option key={n} value={n}>
+                                                               {t(`tracker.config.goals.${n}`)}
+                                                       </option>
+                                               )}
+                                       </Form.Select>
+                               </Form.Group>
+                       </div>
+                       <div>
+                               <AutoTracking />
+                       </div>
+               </Container>
+               <ConfigDialog onHide={() => setShowConfigDialog(false)} show={showConfigDialog} />
+       </Navbar>;
+};
+
+export default Toolbar;
diff --git a/resources/js/components/tracker/index.js b/resources/js/components/tracker/index.js
deleted file mode 100644 (file)
index 68f7972..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-
-import Canvas from './Canvas';
-import Toolbar from './Toolbar';
-
-const Tracker = () => {
-       return <div className="tracker">
-               <Toolbar />
-               <Canvas />
-       </div>;
-};
-
-export default Tracker;
diff --git a/resources/js/components/tracker/index.jsx b/resources/js/components/tracker/index.jsx
new file mode 100644 (file)
index 0000000..68f7972
--- /dev/null
@@ -0,0 +1,13 @@
+import React from 'react';
+
+import Canvas from './Canvas';
+import Toolbar from './Toolbar';
+
+const Tracker = () => {
+       return <div className="tracker">
+               <Toolbar />
+               <Canvas />
+       </div>;
+};
+
+export default Tracker;
diff --git a/resources/js/components/twitch-bot/ChatSettingsForm.js b/resources/js/components/twitch-bot/ChatSettingsForm.js
deleted file mode 100644 (file)
index 5810a3f..0000000
+++ /dev/null
@@ -1,262 +0,0 @@
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import { formatTime, parseTime } from '../../helpers/Result';
-import yup from '../../schema/yup';
-
-const ChatSettingsForm = ({
-       dirty,
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       isSubmitting,
-       touched,
-       values,
-}) => {
-       const { t } = useTranslation();
-
-       return <Form noValidate onSubmit={handleSubmit}>
-               <Row>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.wait_msgs_min">
-                               <Form.Label>{t('twitchBot.chatWaitMsgsMin')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.wait_msgs_min && errors.wait_msgs_min)}
-                                       name="wait_msgs_min"
-                                       min="1"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="number"
-                                       value={values.wait_msgs_min || 1}
-                               />
-                       </Form.Group>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.wait_msgs_max">
-                               <Form.Label>{t('twitchBot.chatWaitMsgsMax')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.wait_msgs_max && errors.wait_msgs_max)}
-                                       name="wait_msgs_max"
-                                       min="1"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="number"
-                                       value={values.wait_msgs_max || 10}
-                               />
-                       </Form.Group>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.wait_time_min">
-                               <Form.Label>{t('twitchBot.chatWaitTimeMin')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.wait_time_min && errors.wait_time_min)}
-                                       name="wait_time_min"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="text"
-                                       value={values.wait_time_min || '0'}
-                               />
-                               {touched.wait_time_min && errors.wait_time_min ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.wait_time_min)}
-                                       </Form.Control.Feedback>
-                               :
-                                       <Form.Text muted>
-                                               {formatTime({ time: parseTime(values.wait_time_min)})}
-                                       </Form.Text>
-                               }
-                       </Form.Group>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.wait_time_max">
-                               <Form.Label>{t('twitchBot.chatWaitTimeMax')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.wait_time_max && errors.wait_time_max)}
-                                       name="wait_time_max"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="text"
-                                       value={values.wait_time_max || '15:00'}
-                               />
-                               {touched.wait_time_max && errors.wait_time_max ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.wait_time_max)}
-                                       </Form.Control.Feedback>
-                               :
-                                       <Form.Text muted>
-                                               {formatTime({ time: parseTime(values.wait_time_max)})}
-                                       </Form.Text>
-                               }
-                       </Form.Group>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.language">
-                               <Form.Label>{t('twitchBot.language')}</Form.Label>
-                               <Form.Select
-                                       isInvalid={!!(touched.language && errors.language)}
-                                       name="language"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.language || 'de'}
-                               >
-                                       {['de', 'en', 'es', 'fr'].map(lang =>
-                                               <option key={lang} value={lang}>
-                                                       {t(`general.languages.${lang}`)}
-                                               </option>
-                                       )}
-                               </Form.Select>
-                               {touched.language && errors.language ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.language)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.respond">
-                               <Form.Label>{t('twitchBot.respond')}</Form.Label>
-                               <Form.Select
-                                       isInvalid={!!(touched.respond && errors.respond)}
-                                       name="respond"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.respond || 'yes'}
-                               >
-                                       {['yes', '50', 'no'].map(value =>
-                                               <option key={value} value={value}>
-                                                       {t(`twitchBot.respondOptions.${value}`)}
-                                               </option>
-                                       )}
-                               </Form.Select>
-                               {touched.respond && errors.respond ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.respond)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.min_age">
-                               <Form.Label>{t('twitchBot.chatMinAge')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.min_age && errors.min_age)}
-                                       name="min_age"
-                                       min="1"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="number"
-                                       value={values.min_age || 1}
-                               />
-                       </Form.Group>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.source">
-                               <Form.Label>{t('twitchBot.chatSource')}</Form.Label>
-                               <Form.Select
-                                       isInvalid={!!(touched.source && errors.source)}
-                                       name="source"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.source || 'any'}
-                               >
-                                       {['any', 'cat', 'chan', 'catchan'].map(value =>
-                                               <option key={value} value={value}>
-                                                       {t(`twitchBot.chatSources.${value}`)}
-                                               </option>
-                                       )}
-                               </Form.Select>
-                               {touched.respond && errors.respond ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.respond)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group as={Col} md={6} controlId="chatSettings.adlib">
-                               <Form.Label>{t('twitchBot.chatAdlibChance')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.adlib && errors.adlib)}
-                                       name="adlib"
-                                       min="0"
-                                       max="100"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       type="number"
-                                       value={Object.prototype.hasOwnProperty.call(values, 'adlib')
-                                               ? values.adlib : 50}
-                               />
-                       </Form.Group>
-               </Row>
-               <div className="button-bar mt-3">
-                       <Button disabled={!dirty || isSubmitting} type="submit" variant="primary">
-                               {t('button.save')}
-                       </Button>
-               </div>
-       </Form>;
-};
-
-ChatSettingsForm.propTypes = {
-       dirty: PropTypes.bool,
-       errors: PropTypes.shape({
-               adlib: PropTypes.string,
-               language: PropTypes.string,
-               min_age: PropTypes.string,
-               respond: PropTypes.string,
-               source: PropTypes.string,
-               wait_msgs_max: PropTypes.string,
-               wait_msgs_min: PropTypes.string,
-               wait_time_min: PropTypes.string,
-               wait_time_max: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       isSubmitting: PropTypes.bool,
-       touched: PropTypes.shape({
-               adlib: PropTypes.bool,
-               language: PropTypes.bool,
-               min_age: PropTypes.bool,
-               respond: PropTypes.bool,
-               source: PropTypes.bool,
-               wait_msgs_max: PropTypes.bool,
-               wait_msgs_min: PropTypes.bool,
-               wait_time_min: PropTypes.bool,
-               wait_time_max: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               adlib: PropTypes.number,
-               language: PropTypes.string,
-               min_age: PropTypes.number,
-               respond: PropTypes.string,
-               source: PropTypes.string,
-               wait_msgs_max: PropTypes.number,
-               wait_msgs_min: PropTypes.number,
-               wait_time_min: PropTypes.string,
-               wait_time_max: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'ChatSettingsForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { onSubmit } = actions.props;
-               await onSubmit({
-                       ...values,
-                       wait_time_min: parseTime(values.wait_time_min) || 0,
-                       wait_time_max: parseTime(values.wait_time_max) || 0,
-               });
-       },
-       mapPropsToValues: ({ channel }) => ({
-               adlib: Object.prototype.hasOwnProperty.call(channel.chat_settings, 'adlib')
-                       ? channel.chat_settings.adlib : 50,
-               language: channel.chat_settings.language || channel.languages[0] || 'de',
-               min_age: channel.chat_settings.min_age || 1,
-               respond: channel.chat_settings.respond || 'yes',
-               source: channel.chat_settings.source || 'any',
-               wait_msgs_min: channel.chat_settings.wait_msgs_min || 1,
-               wait_msgs_max: channel.chat_settings.wait_msgs_max || 10,
-               wait_time_min: channel.chat_settings.wait_time_min
-                       ? formatTime({ time: channel.chat_settings.wait_time_min }) : '0',
-               wait_time_max: channel.chat_settings.wait_time_max
-                       ? formatTime({ time: channel.chat_settings.wait_time_max }) : '15:00',
-       }),
-       validationSchema: yup.object().shape({
-               adlib: yup.number().min(0).max(100),
-               language: yup.string(),
-               min_age: yup.number().min(1),
-               respond: yup.string(),
-               wait_msgs_min: yup.number().min(1),
-               wait_msgs_max: yup.number().min(1),
-               wait_time_min: yup.string().time(),
-               wait_time_max: yup.string().time(),
-       }),
-})(ChatSettingsForm);
diff --git a/resources/js/components/twitch-bot/ChatSettingsForm.jsx b/resources/js/components/twitch-bot/ChatSettingsForm.jsx
new file mode 100644 (file)
index 0000000..5810a3f
--- /dev/null
@@ -0,0 +1,262 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { formatTime, parseTime } from '../../helpers/Result';
+import yup from '../../schema/yup';
+
+const ChatSettingsForm = ({
+       dirty,
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       isSubmitting,
+       touched,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <Row>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.wait_msgs_min">
+                               <Form.Label>{t('twitchBot.chatWaitMsgsMin')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.wait_msgs_min && errors.wait_msgs_min)}
+                                       name="wait_msgs_min"
+                                       min="1"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="number"
+                                       value={values.wait_msgs_min || 1}
+                               />
+                       </Form.Group>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.wait_msgs_max">
+                               <Form.Label>{t('twitchBot.chatWaitMsgsMax')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.wait_msgs_max && errors.wait_msgs_max)}
+                                       name="wait_msgs_max"
+                                       min="1"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="number"
+                                       value={values.wait_msgs_max || 10}
+                               />
+                       </Form.Group>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.wait_time_min">
+                               <Form.Label>{t('twitchBot.chatWaitTimeMin')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.wait_time_min && errors.wait_time_min)}
+                                       name="wait_time_min"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.wait_time_min || '0'}
+                               />
+                               {touched.wait_time_min && errors.wait_time_min ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.wait_time_min)}
+                                       </Form.Control.Feedback>
+                               :
+                                       <Form.Text muted>
+                                               {formatTime({ time: parseTime(values.wait_time_min)})}
+                                       </Form.Text>
+                               }
+                       </Form.Group>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.wait_time_max">
+                               <Form.Label>{t('twitchBot.chatWaitTimeMax')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.wait_time_max && errors.wait_time_max)}
+                                       name="wait_time_max"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.wait_time_max || '15:00'}
+                               />
+                               {touched.wait_time_max && errors.wait_time_max ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.wait_time_max)}
+                                       </Form.Control.Feedback>
+                               :
+                                       <Form.Text muted>
+                                               {formatTime({ time: parseTime(values.wait_time_max)})}
+                                       </Form.Text>
+                               }
+                       </Form.Group>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.language">
+                               <Form.Label>{t('twitchBot.language')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.language && errors.language)}
+                                       name="language"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.language || 'de'}
+                               >
+                                       {['de', 'en', 'es', 'fr'].map(lang =>
+                                               <option key={lang} value={lang}>
+                                                       {t(`general.languages.${lang}`)}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.language && errors.language ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.language)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.respond">
+                               <Form.Label>{t('twitchBot.respond')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.respond && errors.respond)}
+                                       name="respond"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.respond || 'yes'}
+                               >
+                                       {['yes', '50', 'no'].map(value =>
+                                               <option key={value} value={value}>
+                                                       {t(`twitchBot.respondOptions.${value}`)}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.respond && errors.respond ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.respond)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.min_age">
+                               <Form.Label>{t('twitchBot.chatMinAge')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.min_age && errors.min_age)}
+                                       name="min_age"
+                                       min="1"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="number"
+                                       value={values.min_age || 1}
+                               />
+                       </Form.Group>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.source">
+                               <Form.Label>{t('twitchBot.chatSource')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.source && errors.source)}
+                                       name="source"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.source || 'any'}
+                               >
+                                       {['any', 'cat', 'chan', 'catchan'].map(value =>
+                                               <option key={value} value={value}>
+                                                       {t(`twitchBot.chatSources.${value}`)}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.respond && errors.respond ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.respond)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} md={6} controlId="chatSettings.adlib">
+                               <Form.Label>{t('twitchBot.chatAdlibChance')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.adlib && errors.adlib)}
+                                       name="adlib"
+                                       min="0"
+                                       max="100"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="number"
+                                       value={Object.prototype.hasOwnProperty.call(values, 'adlib')
+                                               ? values.adlib : 50}
+                               />
+                       </Form.Group>
+               </Row>
+               <div className="button-bar mt-3">
+                       <Button disabled={!dirty || isSubmitting} type="submit" variant="primary">
+                               {t('button.save')}
+                       </Button>
+               </div>
+       </Form>;
+};
+
+ChatSettingsForm.propTypes = {
+       dirty: PropTypes.bool,
+       errors: PropTypes.shape({
+               adlib: PropTypes.string,
+               language: PropTypes.string,
+               min_age: PropTypes.string,
+               respond: PropTypes.string,
+               source: PropTypes.string,
+               wait_msgs_max: PropTypes.string,
+               wait_msgs_min: PropTypes.string,
+               wait_time_min: PropTypes.string,
+               wait_time_max: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       isSubmitting: PropTypes.bool,
+       touched: PropTypes.shape({
+               adlib: PropTypes.bool,
+               language: PropTypes.bool,
+               min_age: PropTypes.bool,
+               respond: PropTypes.bool,
+               source: PropTypes.bool,
+               wait_msgs_max: PropTypes.bool,
+               wait_msgs_min: PropTypes.bool,
+               wait_time_min: PropTypes.bool,
+               wait_time_max: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               adlib: PropTypes.number,
+               language: PropTypes.string,
+               min_age: PropTypes.number,
+               respond: PropTypes.string,
+               source: PropTypes.string,
+               wait_msgs_max: PropTypes.number,
+               wait_msgs_min: PropTypes.number,
+               wait_time_min: PropTypes.string,
+               wait_time_max: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'ChatSettingsForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { onSubmit } = actions.props;
+               await onSubmit({
+                       ...values,
+                       wait_time_min: parseTime(values.wait_time_min) || 0,
+                       wait_time_max: parseTime(values.wait_time_max) || 0,
+               });
+       },
+       mapPropsToValues: ({ channel }) => ({
+               adlib: Object.prototype.hasOwnProperty.call(channel.chat_settings, 'adlib')
+                       ? channel.chat_settings.adlib : 50,
+               language: channel.chat_settings.language || channel.languages[0] || 'de',
+               min_age: channel.chat_settings.min_age || 1,
+               respond: channel.chat_settings.respond || 'yes',
+               source: channel.chat_settings.source || 'any',
+               wait_msgs_min: channel.chat_settings.wait_msgs_min || 1,
+               wait_msgs_max: channel.chat_settings.wait_msgs_max || 10,
+               wait_time_min: channel.chat_settings.wait_time_min
+                       ? formatTime({ time: channel.chat_settings.wait_time_min }) : '0',
+               wait_time_max: channel.chat_settings.wait_time_max
+                       ? formatTime({ time: channel.chat_settings.wait_time_max }) : '15:00',
+       }),
+       validationSchema: yup.object().shape({
+               adlib: yup.number().min(0).max(100),
+               language: yup.string(),
+               min_age: yup.number().min(1),
+               respond: yup.string(),
+               wait_msgs_min: yup.number().min(1),
+               wait_msgs_max: yup.number().min(1),
+               wait_time_min: yup.string().time(),
+               wait_time_max: yup.string().time(),
+       }),
+})(ChatSettingsForm);
diff --git a/resources/js/components/twitch-bot/Command.js b/resources/js/components/twitch-bot/Command.js
deleted file mode 100644 (file)
index 9122086..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from '../common/Icon';
-
-const Command = ({
-       name,
-       onEditCommand,
-       onRemoveCommand,
-       settings,
-}) => {
-       const { t } = useTranslation();
-
-       const type = (settings && settings.command) || 'none';
-
-       return <tr>
-               <td>{`!${name}`}</td>
-               <td>{t(`twitchBot.commandRestrictions.${(settings && settings.restrict) || 'none'}`)}</td>
-               <td>{t(`twitchBot.commandTypes.${type}`)}</td>
-               <td className="text-end">
-                       <div className="button-bar">
-                               {onEditCommand ?
-                                       <Button
-                                               onClick={() => onEditCommand(name, settings)}
-                                               title={t('button.edit')}
-                                               variant="outline-secondary"
-                                       >
-                                               <Icon.EDIT title="" />
-                                       </Button>
-                               : null}
-                               {onRemoveCommand ?
-                                       <Button
-                                               onClick={() => onRemoveCommand(name)}
-                                               title={t('button.remove')}
-                                               variant="outline-danger"
-                                       >
-                                               <Icon.REMOVE title="" />
-                                       </Button>
-                               : null}
-                       </div>
-               </td>
-       </tr>;
-};
-
-Command.propTypes = {
-       name: PropTypes.string,
-       onEditCommand: PropTypes.func,
-       onRemoveCommand: PropTypes.func,
-       settings: PropTypes.shape({
-               command: PropTypes.string,
-               restrict: PropTypes.string,
-       }),
-};
-
-export default Command;
diff --git a/resources/js/components/twitch-bot/Command.jsx b/resources/js/components/twitch-bot/Command.jsx
new file mode 100644 (file)
index 0000000..9122086
--- /dev/null
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+
+const Command = ({
+       name,
+       onEditCommand,
+       onRemoveCommand,
+       settings,
+}) => {
+       const { t } = useTranslation();
+
+       const type = (settings && settings.command) || 'none';
+
+       return <tr>
+               <td>{`!${name}`}</td>
+               <td>{t(`twitchBot.commandRestrictions.${(settings && settings.restrict) || 'none'}`)}</td>
+               <td>{t(`twitchBot.commandTypes.${type}`)}</td>
+               <td className="text-end">
+                       <div className="button-bar">
+                               {onEditCommand ?
+                                       <Button
+                                               onClick={() => onEditCommand(name, settings)}
+                                               title={t('button.edit')}
+                                               variant="outline-secondary"
+                                       >
+                                               <Icon.EDIT title="" />
+                                       </Button>
+                               : null}
+                               {onRemoveCommand ?
+                                       <Button
+                                               onClick={() => onRemoveCommand(name)}
+                                               title={t('button.remove')}
+                                               variant="outline-danger"
+                                       >
+                                               <Icon.REMOVE title="" />
+                                       </Button>
+                               : null}
+                       </div>
+               </td>
+       </tr>;
+};
+
+Command.propTypes = {
+       name: PropTypes.string,
+       onEditCommand: PropTypes.func,
+       onRemoveCommand: PropTypes.func,
+       settings: PropTypes.shape({
+               command: PropTypes.string,
+               restrict: PropTypes.string,
+       }),
+};
+
+export default Command;
diff --git a/resources/js/components/twitch-bot/CommandDialog.js b/resources/js/components/twitch-bot/CommandDialog.js
deleted file mode 100644 (file)
index 7e8ce16..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import CommandForm from './CommandForm';
-
-const CommandDialog = ({
-       name,
-       onHide,
-       onSubmit,
-       settings,
-       show,
-}) => {
-       const { t } = useTranslation();
-
-       return <Modal className="report-dialog" onHide={onHide} show={show}>
-               <Modal.Header closeButton>
-                       <Modal.Title>
-                               {t(name ? 'twitchBot.commandDialog' : 'twitchBot.addCommand')}
-                       </Modal.Title>
-               </Modal.Header>
-               <CommandForm
-                       name={name}
-                       onCancel={onHide}
-                       onSubmit={onSubmit}
-                       settings={settings}
-               />
-       </Modal>;
-};
-
-CommandDialog.propTypes = {
-       name: PropTypes.string,
-       onHide: PropTypes.func,
-       onSubmit: PropTypes.func,
-       settings: PropTypes.shape({
-       }),
-       show: PropTypes.bool,
-};
-
-export default CommandDialog;
diff --git a/resources/js/components/twitch-bot/CommandDialog.jsx b/resources/js/components/twitch-bot/CommandDialog.jsx
new file mode 100644 (file)
index 0000000..7e8ce16
--- /dev/null
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import CommandForm from './CommandForm';
+
+const CommandDialog = ({
+       name,
+       onHide,
+       onSubmit,
+       settings,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal className="report-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t(name ? 'twitchBot.commandDialog' : 'twitchBot.addCommand')}
+                       </Modal.Title>
+               </Modal.Header>
+               <CommandForm
+                       name={name}
+                       onCancel={onHide}
+                       onSubmit={onSubmit}
+                       settings={settings}
+               />
+       </Modal>;
+};
+
+CommandDialog.propTypes = {
+       name: PropTypes.string,
+       onHide: PropTypes.func,
+       onSubmit: PropTypes.func,
+       settings: PropTypes.shape({
+       }),
+       show: PropTypes.bool,
+};
+
+export default CommandDialog;
diff --git a/resources/js/components/twitch-bot/CommandForm.js b/resources/js/components/twitch-bot/CommandForm.js
deleted file mode 100644 (file)
index d922170..0000000
+++ /dev/null
@@ -1,164 +0,0 @@
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Form, Modal } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import yup from '../../schema/yup';
-
-const CommandForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       name,
-       onCancel,
-       touched,
-       values,
-}) => {
-       const { t } = useTranslation();
-
-       const COMMANDS = [
-               'none',
-               'runner',
-               'crew',
-               'guessing-start',
-               'guessing-stop',
-               'guessing-solve',
-               'guessing-cancel',
-               'guessing-leaderboard',
-       ];
-       const RESTRICTIONS = [
-               'none',
-               'mod',
-               'owner',
-       ];
-
-       return <Form noValidate onSubmit={handleSubmit}>
-               <Modal.Body>
-                       <Form.Group controlId="command.name">
-                               <Form.Label>{t('twitchBot.commandName')}</Form.Label>
-                               <Form.Control
-                                       disabled={!!name}
-                                       isInvalid={!!(touched.name && errors.name)}
-                                       name="name"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       plaintext={!!name}
-                                       readOnly={!!name}
-                                       type="text"
-                                       value={values.name || ''}
-                               />
-                               {touched.name && errors.name ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.name)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group controlId="command.restrict">
-                               <Form.Label>{t('twitchBot.commandRestriction')}</Form.Label>
-                               <Form.Select
-                                       isInvalid={!!(touched.restrict && errors.restrict)}
-                                       name="restrict"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.restrict || 'none'}
-                               >
-                                       {RESTRICTIONS.map(r =>
-                                               <option key={r} value={r}>
-                                                       {t(`twitchBot.commandRestrictions.${r}`)}
-                                               </option>
-                                       )}
-                               </Form.Select>
-                               {touched.restrict && errors.restrict ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.restrict)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group controlId="command.command">
-                               <Form.Label>{t('twitchBot.commandType')}</Form.Label>
-                               <Form.Select
-                                       isInvalid={!!(touched.command && errors.command)}
-                                       name="command"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.command || 'none'}
-                               >
-                                       {COMMANDS.map(c =>
-                                               <option key={c} value={c}>
-                                                       {t(`twitchBot.commandTypes.${c}`)}
-                                               </option>
-                                       )}
-                               </Form.Select>
-                               {touched.command && errors.command ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.command)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Modal.Body>
-               <Modal.Footer>
-                       {onCancel ?
-                               <Button onClick={onCancel} variant="secondary">
-                                       {t('button.cancel')}
-                               </Button>
-                       : null}
-                       <Button type="submit" variant="primary">
-                               {t('button.save')}
-                       </Button>
-               </Modal.Footer>
-       </Form>;
-};
-
-CommandForm.propTypes = {
-       errors: PropTypes.shape({
-               command: PropTypes.string,
-               name: PropTypes.string,
-               restrict: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       name: PropTypes.string,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               command: PropTypes.bool,
-               name: PropTypes.bool,
-               restrict: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               command: PropTypes.string,
-               name: PropTypes.string,
-               restrict: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'CommandForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { setErrors } = actions;
-               const { onSubmit } = actions.props;
-               try {
-                       await onSubmit(values);
-               } catch (e) {
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ name, settings }) => {
-               return {
-                       command: (settings && settings.command) || 'none',
-                       name: name || '',
-                       restrict: (settings && settings.restrict) || 'none',
-               };
-       },
-       validationSchema: yup.object().shape({
-               command: yup.string(),
-               name: yup.string().required(),
-               restrict: yup.string(),
-       }),
-})(CommandForm);
diff --git a/resources/js/components/twitch-bot/CommandForm.jsx b/resources/js/components/twitch-bot/CommandForm.jsx
new file mode 100644 (file)
index 0000000..d922170
--- /dev/null
@@ -0,0 +1,164 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import yup from '../../schema/yup';
+
+const CommandForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       name,
+       onCancel,
+       touched,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       const COMMANDS = [
+               'none',
+               'runner',
+               'crew',
+               'guessing-start',
+               'guessing-stop',
+               'guessing-solve',
+               'guessing-cancel',
+               'guessing-leaderboard',
+       ];
+       const RESTRICTIONS = [
+               'none',
+               'mod',
+               'owner',
+       ];
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <Modal.Body>
+                       <Form.Group controlId="command.name">
+                               <Form.Label>{t('twitchBot.commandName')}</Form.Label>
+                               <Form.Control
+                                       disabled={!!name}
+                                       isInvalid={!!(touched.name && errors.name)}
+                                       name="name"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       plaintext={!!name}
+                                       readOnly={!!name}
+                                       type="text"
+                                       value={values.name || ''}
+                               />
+                               {touched.name && errors.name ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.name)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group controlId="command.restrict">
+                               <Form.Label>{t('twitchBot.commandRestriction')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.restrict && errors.restrict)}
+                                       name="restrict"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.restrict || 'none'}
+                               >
+                                       {RESTRICTIONS.map(r =>
+                                               <option key={r} value={r}>
+                                                       {t(`twitchBot.commandRestrictions.${r}`)}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.restrict && errors.restrict ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.restrict)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group controlId="command.command">
+                               <Form.Label>{t('twitchBot.commandType')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.command && errors.command)}
+                                       name="command"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.command || 'none'}
+                               >
+                                       {COMMANDS.map(c =>
+                                               <option key={c} value={c}>
+                                                       {t(`twitchBot.commandTypes.${c}`)}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.command && errors.command ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.command)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Modal.Body>
+               <Modal.Footer>
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.cancel')}
+                               </Button>
+                       : null}
+                       <Button type="submit" variant="primary">
+                               {t('button.save')}
+                       </Button>
+               </Modal.Footer>
+       </Form>;
+};
+
+CommandForm.propTypes = {
+       errors: PropTypes.shape({
+               command: PropTypes.string,
+               name: PropTypes.string,
+               restrict: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       name: PropTypes.string,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               command: PropTypes.bool,
+               name: PropTypes.bool,
+               restrict: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               command: PropTypes.string,
+               name: PropTypes.string,
+               restrict: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'CommandForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { setErrors } = actions;
+               const { onSubmit } = actions.props;
+               try {
+                       await onSubmit(values);
+               } catch (e) {
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ name, settings }) => {
+               return {
+                       command: (settings && settings.command) || 'none',
+                       name: name || '',
+                       restrict: (settings && settings.restrict) || 'none',
+               };
+       },
+       validationSchema: yup.object().shape({
+               command: yup.string(),
+               name: yup.string().required(),
+               restrict: yup.string(),
+       }),
+})(CommandForm);
diff --git a/resources/js/components/twitch-bot/Commands.js b/resources/js/components/twitch-bot/Commands.js
deleted file mode 100644 (file)
index 98600d0..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Table } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Command from './Command';
-
-const Commands = ({
-       channel,
-       onEditCommand,
-       onRemoveCommand,
-}) => {
-       const { t } = useTranslation();
-
-       return channel.chat_commands ?
-               <Table>
-                       <thead>
-                               <tr>
-                                       <th>{t('twitchBot.commandName')}</th>
-                                       <th>{t('twitchBot.commandRestriction')}</th>
-                                       <th>{t('twitchBot.commandType')}</th>
-                                       <th className="text-end">{t('general.actions')}</th>
-                               </tr>
-                       </thead>
-                       <tbody>
-                               {Object.entries(channel.chat_commands).map(([name, settings]) =>
-                                       <Command
-                                               key={name}
-                                               name={name}
-                                               onEditCommand={onEditCommand}
-                                               onRemoveCommand={onRemoveCommand}
-                                               settings={settings}
-                                       />
-                               )}
-                       </tbody>
-               </Table>
-       : null;
-};
-
-Commands.propTypes = {
-       channel: PropTypes.shape({
-               chat_commands: PropTypes.shape({
-               }),
-       }),
-       onEditCommand: PropTypes.func,
-       onRemoveCommand: PropTypes.func,
-};
-
-export default Commands;
diff --git a/resources/js/components/twitch-bot/Commands.jsx b/resources/js/components/twitch-bot/Commands.jsx
new file mode 100644 (file)
index 0000000..98600d0
--- /dev/null
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Table } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Command from './Command';
+
+const Commands = ({
+       channel,
+       onEditCommand,
+       onRemoveCommand,
+}) => {
+       const { t } = useTranslation();
+
+       return channel.chat_commands ?
+               <Table>
+                       <thead>
+                               <tr>
+                                       <th>{t('twitchBot.commandName')}</th>
+                                       <th>{t('twitchBot.commandRestriction')}</th>
+                                       <th>{t('twitchBot.commandType')}</th>
+                                       <th className="text-end">{t('general.actions')}</th>
+                               </tr>
+                       </thead>
+                       <tbody>
+                               {Object.entries(channel.chat_commands).map(([name, settings]) =>
+                                       <Command
+                                               key={name}
+                                               name={name}
+                                               onEditCommand={onEditCommand}
+                                               onRemoveCommand={onRemoveCommand}
+                                               settings={settings}
+                                       />
+                               )}
+                       </tbody>
+               </Table>
+       : null;
+};
+
+Commands.propTypes = {
+       channel: PropTypes.shape({
+               chat_commands: PropTypes.shape({
+               }),
+       }),
+       onEditCommand: PropTypes.func,
+       onRemoveCommand: PropTypes.func,
+};
+
+export default Commands;
diff --git a/resources/js/components/twitch-bot/Controls.js b/resources/js/components/twitch-bot/Controls.js
deleted file mode 100644 (file)
index 394f546..0000000
+++ /dev/null
@@ -1,350 +0,0 @@
-import axios from 'axios';
-import React from 'react';
-import { Alert, Button, Col, Form, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import ChatSettingsForm from './ChatSettingsForm';
-import CommandDialog from './CommandDialog';
-import Commands from './Commands';
-import GuessingSettingsForm from './GuessingSettingsForm';
-import ChatBotLog from '../chat-bot-logs/ChatBotLog';
-import ChannelSelect from '../common/ChannelSelect';
-import Icon from '../common/Icon';
-import ToggleSwitch from '../common/ToggleSwitch';
-
-const CHAT_CATEGORIES = [
-       'unclassified',
-       'hi',
-       'gl',
-       'gg',
-       'eyes',
-       'love',
-       'lol',
-       'yes',
-       'no',
-       'rage',
-       'sad',
-       'sweat',
-       'wtf',
-       'pog',
-       'hype',
-       'kappa',
-       'o7',
-       'question',
-       'thx',
-];
-
-const Controls = () => {
-       const [channel, setChannel] = React.useState(null);
-       const [chatText, setChatText] = React.useState('');
-       const [editCommand, setEditCommand] = React.useState('');
-       const [editCommandSettings, setEditCommandSettings] = React.useState({});
-       const [showCommandDialog, setShowCommandDialog] = React.useState(false);
-
-       const { t } = useTranslation();
-
-       const chat = React.useCallback(async (text, bot_nick) => {
-               try {
-                       await axios.post(`/api/channels/${channel.id}/chat`, {
-                               text,
-                               bot_nick,
-                       });
-                       toastr.success(t('twitchBot.chatSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.chatError'));
-               }
-       }, [channel, chatText, t]);
-
-       const randomChat = React.useCallback(async (category) => {
-               try {
-                       await axios.post(`/api/channels/${channel.id}/chat`, {
-                               bot_nick: 'horstiebot',
-                               category,
-                       });
-                       toastr.success(t('twitchBot.chatSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.chatError'));
-               }
-       }, [channel, chatText, t]);
-
-       const adlibChat = React.useCallback(async () => {
-               try {
-                       await axios.post(`/api/channels/${channel.id}/chat`, {
-                               bot_nick: 'horstiebot',
-                               adlib: true,
-                       });
-                       toastr.success(t('twitchBot.chatSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.chatError'));
-               }
-       }, [channel, chatText, t]);
-
-       const join = React.useCallback(async (bot_nick) => {
-               try {
-                       const rsp = await axios.post(`/api/channels/${channel.id}/join`, { bot_nick });
-                       setChannel(rsp.data);
-                       toastr.success(t('twitchBot.joinSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.joinError'));
-               }
-       }, [channel, t]);
-
-       const part = React.useCallback(async (bot_nick) => {
-               try {
-                       const rsp = await axios.post(`/api/channels/${channel.id}/part`, { bot_nick });
-                       setChannel(rsp.data);
-                       toastr.success(t('twitchBot.partSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.partError'));
-               }
-       }, [channel, t]);
-
-       const saveChatSettings = React.useCallback(async (values) => {
-               try {
-                       const rsp = await axios.post(`/api/channels/${channel.id}/chat-settings`, values);
-                       setChannel(rsp.data);
-                       toastr.success(t('twitchBot.saveSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.saveError'));
-               }
-       }, [channel, t]);
-
-       const onAddCommand = React.useCallback(() => {
-               setEditCommand('');
-               setEditCommandSettings({});
-               setShowCommandDialog(true);
-       }, [channel]);
-
-       const onEditCommand = React.useCallback((name, settings) => {
-               setEditCommand(name);
-               setEditCommandSettings(settings);
-               setShowCommandDialog(true);
-       }, [channel]);
-
-       const onRemoveCommand = React.useCallback(async (name) => {
-               try {
-                       const rsp = await axios.delete(`/api/channels/${channel.id}/commands/${name}`);
-                       setChannel(rsp.data);
-                       toastr.success(t('twitchBot.saveSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.saveError'));
-               }
-       }, [channel]);
-
-       const saveCommand = React.useCallback(async (values) => {
-               try {
-                       const rsp = await axios.put(
-                               `/api/channels/${channel.id}/commands/${values.name}`,
-                               values,
-                       );
-                       setChannel(rsp.data);
-                       setShowCommandDialog(false);
-                       setEditCommand('');
-                       setEditCommandSettings({});
-                       toastr.success(t('twitchBot.saveSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.saveError'));
-                       throw e;
-               }
-       }, [channel]);
-
-       const saveGuessingGame = React.useCallback(async (values) => {
-               try {
-                       const rsp = await axios.put(
-                               `/api/channels/${channel.id}/guessing-game/${values.name}`,
-                               values,
-                       );
-                       setChannel(rsp.data);
-                       toastr.success(t('twitchBot.saveSuccess'));
-               } catch (e) {
-                       toastr.error(t('twitchBot.saveError'));
-                       throw e;
-               }
-       }, [channel]);
-
-       return <>
-               <Row className="mb-4">
-                       <Form.Group as={Col} md={6}>
-                               <Form.Label>{t('twitchBot.channel')}</Form.Label>
-                               <Form.Control
-                                       as={ChannelSelect}
-                                       autoSelect
-                                       joinable
-                                       manageable
-                                       onChange={({ channel }) => { setChannel(channel); }}
-                                       value={channel ? channel.id : ''}
-                               />
-                       </Form.Group>
-                       {channel ? <>
-                               <Form.Group as={Col} md={3}>
-                                       <Form.Label>{t('twitchBot.joinApp')}</Form.Label>
-                                       <div>
-                                               <Form.Control
-                                                       as={ToggleSwitch}
-                                                       onChange={({ target: { value } }) => {
-                                                               if (value) {
-                                                                       join('localhorsttv');
-                                                               } else {
-                                                                       part('localhorsttv');
-                                                               }
-                                                       }}
-                                                       value={channel.join}
-                                               />
-                                       </div>
-                               </Form.Group>
-                               <Form.Group as={Col} md={3}>
-                                       <Form.Label>{t('twitchBot.joinChat')}</Form.Label>
-                                       <div>
-                                               <Form.Control
-                                                       as={ToggleSwitch}
-                                                       onChange={({ target: { value } }) => {
-                                                               if (value) {
-                                                                       join('horstiebot');
-                                                               } else {
-                                                                       part('horstiebot');
-                                                               }
-                                                       }}
-                                                       value={channel.chat}
-                                               />
-                                       </div>
-                               </Form.Group>
-                       </> : null}
-               </Row>
-               {channel ?
-                       <Row>
-                               <Col className="mt-5" md={6}>
-                                       <h3>{t('twitchBot.chat')}</h3>
-                                       <Form.Group>
-                                               <Form.Label>{t('twitchBot.chat')}</Form.Label>
-                                               <Form.Control
-                                                       as="textarea"
-                                                       onChange={({ target: { value } }) => {
-                                                               setChatText(value);
-                                                       }}
-                                                       value={chatText}
-                                               />
-                                               <div className="button-bar">
-                                                       <Button
-                                                               className="mt-2"
-                                                               disabled={!chatText || !channel.join}
-                                                               onClick={() => {
-                                                                       if (chatText) chat(chatText, 'localhorsttv');
-                                                               }}
-                                                               variant="twitch"
-                                                       >
-                                                               {t('twitchBot.sendApp')}
-                                                       </Button>
-                                                       <Button
-                                                               className="mt-2"
-                                                               disabled={!chatText || !channel.chat}
-                                                               onClick={() => {
-                                                                       if (chatText) chat(chatText, 'horstiebot');
-                                                               }}
-                                                               variant="twitch"
-                                                       >
-                                                               {t('twitchBot.sendChat')}
-                                                       </Button>
-                                               </div>
-                                       </Form.Group>
-                                       <h3 className="mt-3">{t('twitchBot.randomChat')}</h3>
-                                       <div className="button-bar">
-                                               {CHAT_CATEGORIES.map(category =>
-                                                       <Button
-                                                               key={category}
-                                                               onClick={() => { randomChat(category); }}
-                                                               variant="outline-secondary"
-                                                       >
-                                                               {t(`twitchBot.chatCategories.${category}`)}
-                                                       </Button>
-                                               )}
-                                       </div>
-                                       <div className="mt-3">
-                                               <Button
-                                                       onClick={() => { adlibChat(); }}
-                                                       title={t('twitchBot.adlibChatDesc')}
-                                                       variant="outline-secondary"
-                                               >
-                                                       {t('twitchBot.adlibChat')}
-                                               </Button>
-                                               <p className="text-muted">{t('twitchBot.adlibChatNote')}</p>
-                                       </div>
-                               </Col>
-                               <Col className="mt-5" md={6}>
-                                       <div className="d-flex justify-content-between">
-                                               <h3>{t('twitchBot.chatSettings')}</h3>
-                                               <div className="button-bar">
-                                                       <ChatBotLog id={channel.id} />
-                                               </div>
-                                       </div>
-                                       <ChatSettingsForm channel={channel} onSubmit={saveChatSettings} />
-                               </Col>
-                               <Col className="mt-5" md={12}>
-                                       <h3>{t('twitchBot.commands')}</h3>
-                                       <Commands
-                                               channel={channel}
-                                               onEditCommand={onEditCommand}
-                                               onRemoveCommand={onRemoveCommand}
-                                       />
-                                       <CommandDialog
-                                               name={editCommand}
-                                               onHide={() => {
-                                                       setShowCommandDialog(false);
-                                                       setEditCommand('');
-                                                       setEditCommandSettings({});
-                                               }}
-                                               onSubmit={saveCommand}
-                                               settings={editCommandSettings}
-                                               show={showCommandDialog}
-                                       />
-                                       <div>
-                                               <Button onClick={onAddCommand} variant="primary">
-                                                       {t('twitchBot.addCommand')}
-                                               </Button>
-                                       </div>
-                               </Col>
-                               <Col className="mt-5" md={12}>
-                                       <div className="d-flex align-items-end justify-content-between">
-                                               <h3>{t('twitchBot.guessingGame.settings')}</h3>
-                                               <div className="button-bar">
-                                                       {channel.access_key ?
-                                                               <Button
-                                                                       href={`/guessing-game/monitor/${channel.access_key}`}
-                                                                       target="_blank"
-                                                                       title={t('button.browserSource')}
-                                                                       variant="outline-secondary"
-                                                               >
-                                                                       <Icon.BROWSER_SOURCE title="" />
-                                                               </Button>
-                                                       : null}
-                                                       <Button
-                                                               onClick={() => {
-                                                                       window.open(
-                                                                               `/guessing-game/controls/${channel.id}`,
-                                                                               '',
-                                                                               'width=640,height=800,titlebar=0,menubar=0,toolbar=0',
-                                                                       );
-                                                               }}
-                                                               title={t('twitchBot.guessingGame.popoutControls')}
-                                                               variant="outline-secondary"
-                                                       >
-                                                               <Icon.OPEN title="" />
-                                                       </Button>
-                                               </div>
-                                       </div>
-                                       <GuessingSettingsForm
-                                               name="gtbk"
-                                               onSubmit={saveGuessingGame}
-                                               settings={channel.guessing_settings?.gtbk || {}}
-                                       />
-                               </Col>
-                       </Row>
-               :
-                       <Alert variant="info">
-                               {t('twitchBot.selectChannel')}
-                       </Alert>
-               }
-       </>;
-};
-
-export default Controls;
diff --git a/resources/js/components/twitch-bot/Controls.jsx b/resources/js/components/twitch-bot/Controls.jsx
new file mode 100644 (file)
index 0000000..394f546
--- /dev/null
@@ -0,0 +1,350 @@
+import axios from 'axios';
+import React from 'react';
+import { Alert, Button, Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import ChatSettingsForm from './ChatSettingsForm';
+import CommandDialog from './CommandDialog';
+import Commands from './Commands';
+import GuessingSettingsForm from './GuessingSettingsForm';
+import ChatBotLog from '../chat-bot-logs/ChatBotLog';
+import ChannelSelect from '../common/ChannelSelect';
+import Icon from '../common/Icon';
+import ToggleSwitch from '../common/ToggleSwitch';
+
+const CHAT_CATEGORIES = [
+       'unclassified',
+       'hi',
+       'gl',
+       'gg',
+       'eyes',
+       'love',
+       'lol',
+       'yes',
+       'no',
+       'rage',
+       'sad',
+       'sweat',
+       'wtf',
+       'pog',
+       'hype',
+       'kappa',
+       'o7',
+       'question',
+       'thx',
+];
+
+const Controls = () => {
+       const [channel, setChannel] = React.useState(null);
+       const [chatText, setChatText] = React.useState('');
+       const [editCommand, setEditCommand] = React.useState('');
+       const [editCommandSettings, setEditCommandSettings] = React.useState({});
+       const [showCommandDialog, setShowCommandDialog] = React.useState(false);
+
+       const { t } = useTranslation();
+
+       const chat = React.useCallback(async (text, bot_nick) => {
+               try {
+                       await axios.post(`/api/channels/${channel.id}/chat`, {
+                               text,
+                               bot_nick,
+                       });
+                       toastr.success(t('twitchBot.chatSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.chatError'));
+               }
+       }, [channel, chatText, t]);
+
+       const randomChat = React.useCallback(async (category) => {
+               try {
+                       await axios.post(`/api/channels/${channel.id}/chat`, {
+                               bot_nick: 'horstiebot',
+                               category,
+                       });
+                       toastr.success(t('twitchBot.chatSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.chatError'));
+               }
+       }, [channel, chatText, t]);
+
+       const adlibChat = React.useCallback(async () => {
+               try {
+                       await axios.post(`/api/channels/${channel.id}/chat`, {
+                               bot_nick: 'horstiebot',
+                               adlib: true,
+                       });
+                       toastr.success(t('twitchBot.chatSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.chatError'));
+               }
+       }, [channel, chatText, t]);
+
+       const join = React.useCallback(async (bot_nick) => {
+               try {
+                       const rsp = await axios.post(`/api/channels/${channel.id}/join`, { bot_nick });
+                       setChannel(rsp.data);
+                       toastr.success(t('twitchBot.joinSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.joinError'));
+               }
+       }, [channel, t]);
+
+       const part = React.useCallback(async (bot_nick) => {
+               try {
+                       const rsp = await axios.post(`/api/channels/${channel.id}/part`, { bot_nick });
+                       setChannel(rsp.data);
+                       toastr.success(t('twitchBot.partSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.partError'));
+               }
+       }, [channel, t]);
+
+       const saveChatSettings = React.useCallback(async (values) => {
+               try {
+                       const rsp = await axios.post(`/api/channels/${channel.id}/chat-settings`, values);
+                       setChannel(rsp.data);
+                       toastr.success(t('twitchBot.saveSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.saveError'));
+               }
+       }, [channel, t]);
+
+       const onAddCommand = React.useCallback(() => {
+               setEditCommand('');
+               setEditCommandSettings({});
+               setShowCommandDialog(true);
+       }, [channel]);
+
+       const onEditCommand = React.useCallback((name, settings) => {
+               setEditCommand(name);
+               setEditCommandSettings(settings);
+               setShowCommandDialog(true);
+       }, [channel]);
+
+       const onRemoveCommand = React.useCallback(async (name) => {
+               try {
+                       const rsp = await axios.delete(`/api/channels/${channel.id}/commands/${name}`);
+                       setChannel(rsp.data);
+                       toastr.success(t('twitchBot.saveSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.saveError'));
+               }
+       }, [channel]);
+
+       const saveCommand = React.useCallback(async (values) => {
+               try {
+                       const rsp = await axios.put(
+                               `/api/channels/${channel.id}/commands/${values.name}`,
+                               values,
+                       );
+                       setChannel(rsp.data);
+                       setShowCommandDialog(false);
+                       setEditCommand('');
+                       setEditCommandSettings({});
+                       toastr.success(t('twitchBot.saveSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.saveError'));
+                       throw e;
+               }
+       }, [channel]);
+
+       const saveGuessingGame = React.useCallback(async (values) => {
+               try {
+                       const rsp = await axios.put(
+                               `/api/channels/${channel.id}/guessing-game/${values.name}`,
+                               values,
+                       );
+                       setChannel(rsp.data);
+                       toastr.success(t('twitchBot.saveSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.saveError'));
+                       throw e;
+               }
+       }, [channel]);
+
+       return <>
+               <Row className="mb-4">
+                       <Form.Group as={Col} md={6}>
+                               <Form.Label>{t('twitchBot.channel')}</Form.Label>
+                               <Form.Control
+                                       as={ChannelSelect}
+                                       autoSelect
+                                       joinable
+                                       manageable
+                                       onChange={({ channel }) => { setChannel(channel); }}
+                                       value={channel ? channel.id : ''}
+                               />
+                       </Form.Group>
+                       {channel ? <>
+                               <Form.Group as={Col} md={3}>
+                                       <Form.Label>{t('twitchBot.joinApp')}</Form.Label>
+                                       <div>
+                                               <Form.Control
+                                                       as={ToggleSwitch}
+                                                       onChange={({ target: { value } }) => {
+                                                               if (value) {
+                                                                       join('localhorsttv');
+                                                               } else {
+                                                                       part('localhorsttv');
+                                                               }
+                                                       }}
+                                                       value={channel.join}
+                                               />
+                                       </div>
+                               </Form.Group>
+                               <Form.Group as={Col} md={3}>
+                                       <Form.Label>{t('twitchBot.joinChat')}</Form.Label>
+                                       <div>
+                                               <Form.Control
+                                                       as={ToggleSwitch}
+                                                       onChange={({ target: { value } }) => {
+                                                               if (value) {
+                                                                       join('horstiebot');
+                                                               } else {
+                                                                       part('horstiebot');
+                                                               }
+                                                       }}
+                                                       value={channel.chat}
+                                               />
+                                       </div>
+                               </Form.Group>
+                       </> : null}
+               </Row>
+               {channel ?
+                       <Row>
+                               <Col className="mt-5" md={6}>
+                                       <h3>{t('twitchBot.chat')}</h3>
+                                       <Form.Group>
+                                               <Form.Label>{t('twitchBot.chat')}</Form.Label>
+                                               <Form.Control
+                                                       as="textarea"
+                                                       onChange={({ target: { value } }) => {
+                                                               setChatText(value);
+                                                       }}
+                                                       value={chatText}
+                                               />
+                                               <div className="button-bar">
+                                                       <Button
+                                                               className="mt-2"
+                                                               disabled={!chatText || !channel.join}
+                                                               onClick={() => {
+                                                                       if (chatText) chat(chatText, 'localhorsttv');
+                                                               }}
+                                                               variant="twitch"
+                                                       >
+                                                               {t('twitchBot.sendApp')}
+                                                       </Button>
+                                                       <Button
+                                                               className="mt-2"
+                                                               disabled={!chatText || !channel.chat}
+                                                               onClick={() => {
+                                                                       if (chatText) chat(chatText, 'horstiebot');
+                                                               }}
+                                                               variant="twitch"
+                                                       >
+                                                               {t('twitchBot.sendChat')}
+                                                       </Button>
+                                               </div>
+                                       </Form.Group>
+                                       <h3 className="mt-3">{t('twitchBot.randomChat')}</h3>
+                                       <div className="button-bar">
+                                               {CHAT_CATEGORIES.map(category =>
+                                                       <Button
+                                                               key={category}
+                                                               onClick={() => { randomChat(category); }}
+                                                               variant="outline-secondary"
+                                                       >
+                                                               {t(`twitchBot.chatCategories.${category}`)}
+                                                       </Button>
+                                               )}
+                                       </div>
+                                       <div className="mt-3">
+                                               <Button
+                                                       onClick={() => { adlibChat(); }}
+                                                       title={t('twitchBot.adlibChatDesc')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       {t('twitchBot.adlibChat')}
+                                               </Button>
+                                               <p className="text-muted">{t('twitchBot.adlibChatNote')}</p>
+                                       </div>
+                               </Col>
+                               <Col className="mt-5" md={6}>
+                                       <div className="d-flex justify-content-between">
+                                               <h3>{t('twitchBot.chatSettings')}</h3>
+                                               <div className="button-bar">
+                                                       <ChatBotLog id={channel.id} />
+                                               </div>
+                                       </div>
+                                       <ChatSettingsForm channel={channel} onSubmit={saveChatSettings} />
+                               </Col>
+                               <Col className="mt-5" md={12}>
+                                       <h3>{t('twitchBot.commands')}</h3>
+                                       <Commands
+                                               channel={channel}
+                                               onEditCommand={onEditCommand}
+                                               onRemoveCommand={onRemoveCommand}
+                                       />
+                                       <CommandDialog
+                                               name={editCommand}
+                                               onHide={() => {
+                                                       setShowCommandDialog(false);
+                                                       setEditCommand('');
+                                                       setEditCommandSettings({});
+                                               }}
+                                               onSubmit={saveCommand}
+                                               settings={editCommandSettings}
+                                               show={showCommandDialog}
+                                       />
+                                       <div>
+                                               <Button onClick={onAddCommand} variant="primary">
+                                                       {t('twitchBot.addCommand')}
+                                               </Button>
+                                       </div>
+                               </Col>
+                               <Col className="mt-5" md={12}>
+                                       <div className="d-flex align-items-end justify-content-between">
+                                               <h3>{t('twitchBot.guessingGame.settings')}</h3>
+                                               <div className="button-bar">
+                                                       {channel.access_key ?
+                                                               <Button
+                                                                       href={`/guessing-game/monitor/${channel.access_key}`}
+                                                                       target="_blank"
+                                                                       title={t('button.browserSource')}
+                                                                       variant="outline-secondary"
+                                                               >
+                                                                       <Icon.BROWSER_SOURCE title="" />
+                                                               </Button>
+                                                       : null}
+                                                       <Button
+                                                               onClick={() => {
+                                                                       window.open(
+                                                                               `/guessing-game/controls/${channel.id}`,
+                                                                               '',
+                                                                               'width=640,height=800,titlebar=0,menubar=0,toolbar=0',
+                                                                       );
+                                                               }}
+                                                               title={t('twitchBot.guessingGame.popoutControls')}
+                                                               variant="outline-secondary"
+                                                       >
+                                                               <Icon.OPEN title="" />
+                                                       </Button>
+                                               </div>
+                                       </div>
+                                       <GuessingSettingsForm
+                                               name="gtbk"
+                                               onSubmit={saveGuessingGame}
+                                               settings={channel.guessing_settings?.gtbk || {}}
+                                       />
+                               </Col>
+                       </Row>
+               :
+                       <Alert variant="info">
+                               {t('twitchBot.selectChannel')}
+                       </Alert>
+               }
+       </>;
+};
+
+export default Controls;
diff --git a/resources/js/components/twitch-bot/GuessingGameAutoTracking.js b/resources/js/components/twitch-bot/GuessingGameAutoTracking.js
deleted file mode 100644 (file)
index 451c597..0000000
+++ /dev/null
@@ -1,327 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import Icon from '../common/Icon';
-import ToggleSwitch from '../common/ToggleSwitch';
-import {
-       compareGTBasementState,
-       countGTBasementState,
-       getGTBasementState,
-       IN_GAME_MODES,
-       INV_ADDR,
-       RAM_ADDR,
-       SRAM_ADDR,
-       WRAM_ADDR,
-} from '../../helpers/alttp-ram';
-import { useSNES } from '../../hooks/snes';
-
-const GT_TYPES = [
-       0x02, // all dungeons
-       0x03, // defeat ganon
-       0x04, // fast ganon (the default and used for defeat ganon for some reason)
-       0x07, // crystals & bosses
-       0x08, // bosses
-       0x09, // all dungeons, no aga 1
-       0x0B, // completionist
-];
-
-const GT_ENTRANCE_ID = 55;
-
-const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
-       const [enabled, setEnabled] = React.useState(false);
-       const controls = React.useRef({
-               onSolve,
-               onStart,
-               onStop,
-       });
-
-       const [inGame, setInGame] = React.useState(false);
-       const [seedType, setSeedType] = React.useState(0);
-       const [gtCrystals, setGTCrystals] = React.useState(0);
-       const [ganonType, setGanonType] = React.useState(0);
-       const [freeItemMenu, setFreeItemMenu] = React.useState(0);
-       const [pyramidOpen, setPyramidOpen] = React.useState(false);
-
-       const [ownedCrystals, setOwnedCrystals] = React.useState(0);
-       const [lastEntrance, setLastEntrance] = React.useState(0);
-       const [hasEntered, setHasEntered] = React.useState(false);
-       const [basement, setBasement] = React.useState({
-               state: getGTBasementState(),
-               last: '',
-               count: 0,
-               torch: 0,
-       });
-       const [hasBigKey, setHasBigKey] = React.useState(false);
-
-       const {
-               disable: disableSNES,
-               enable: enableSNES,
-               openSettings,
-               sock,
-               status,
-       } = useSNES();
-       const { t } = useTranslation();
-
-       React.useEffect(() => {
-               controls.current = {
-                       onSolve,
-                       onStart,
-                       onStop,
-               };
-       }, [onSolve, onStart, onStop]);
-
-       const resetState = React.useCallback(() => {
-               setInGame(false);
-               setSeedType(0);
-               setGTCrystals(0);
-               setGanonType(0);
-               setFreeItemMenu(0);
-
-               setOwnedCrystals(0);
-               setLastEntrance(0);
-               setHasEntered(false);
-               setBasement({
-                       state: getGTBasementState(),
-                       last: '',
-                       count: 0,
-                       torch: 0,
-               });
-               setHasBigKey(false);
-       }, []);
-
-       const enable = React.useCallback(() => {
-               enableSNES();
-               setEnabled(true);
-       }, []);
-
-       const disable = React.useCallback(() => {
-               disableSNES();
-               setEnabled(false);
-               resetState();
-       }, []);
-
-       React.useEffect(() => {
-               const savedSettings = localStorage.getItem('guessingGame.settings');
-               if (savedSettings) {
-                       const settings = JSON.parse(savedSettings);
-                       if (settings.autoTrack) {
-                               enable();
-                       }
-               }
-       }, []);
-
-       const saveSettings = React.useCallback((newSettings) => {
-               const savedSettings = localStorage.getItem('guessingGame.settings');
-               const settings = savedSettings
-                       ? { ...JSON.parse(savedSettings), ...newSettings }
-                       : newSettings;
-               localStorage.setItem('guessingGame.settings', JSON.stringify(settings));
-       }, []);
-
-       const toggle = React.useCallback(() => {
-               if (enabled) {
-                       disable();
-                       saveSettings({ autoTrack: false });
-               } else {
-                       enable();
-                       saveSettings({ autoTrack: true });
-               }
-       }, [enabled]);
-
-       // game mode timer
-       React.useEffect(() => {
-               if (enabled && !status.error && status.connected && status.device) {
-                       const checkInGame = () => {
-                               sock.current.readWRAM(WRAM_ADDR.GAME_MODE, 1, (data) => {
-                                       setInGame(IN_GAME_MODES.includes(data[0]));
-                               });
-                       };
-                       checkInGame();
-                       const timer = setInterval(checkInGame, 5000);
-                       return () => {
-                               clearInterval(timer);
-                       };
-               } else {
-                       setInGame(false);
-               }
-       }, [enabled && !status.error && status.connected && status.device]);
-
-       // refresh static game information
-       React.useEffect(() => {
-               if (!inGame) return;
-               sock.current.readBytes(RAM_ADDR.SEED_TYPE, 1, (data) => {
-                       setSeedType(data[0]);
-               });
-               sock.current.readBytes(RAM_ADDR.GT_CRYSTALS, 1, (data) => {
-                       setGTCrystals(data[0]);
-               });
-               sock.current.readBytes(RAM_ADDR.GANON_TYPE, 1, (data) => {
-                       setGanonType(data[0]);
-               });
-               sock.current.readBytes(RAM_ADDR.FREE_ITEM_MENU, 1, (data) => {
-                       setFreeItemMenu(data[0]);
-               });
-               sock.current.readBytes(RAM_ADDR.INIT_SRAM + SRAM_ADDR.PYRAMID_SCREEN, 1, (data) => {
-                       setPyramidOpen(!!(data[0] & 0x20));
-               });
-       }, [inGame, sock]);
-
-       const applicable = React.useMemo(() => {
-               return !seedType &&
-                       gtCrystals &&
-                       GT_TYPES.includes(ganonType) &&
-                       !pyramidOpen &&
-                       !(freeItemMenu & 0x02);
-       }, [freeItemMenu, ganonType, gtCrystals, pyramidOpen, seedType]);
-
-       // update crystals information
-       React.useEffect(() => {
-               if (!applicable || !inGame || hasBigKey) return;
-               const updateCrystals = () => {
-                       const crAddress = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.INV_START + INV_ADDR.CRYSTALS;
-                       sock.current.readWRAM(crAddress, 1, (data) => {
-                               let owned = 0;
-                               for (let i = 0; i < 7; ++i) {
-                                       if (data[0] & Math.pow(2, i)) {
-                                               ++owned;
-                                       }
-                               }
-                               setOwnedCrystals(owned);
-                       });
-               };
-               // increase frequency for the last
-               const timer = setInterval(updateCrystals, ownedCrystals === gtCrystals - 1 ? 1000 : 15000);
-               return () => {
-                       clearInterval(timer);
-               };
-       }, [applicable, gtCrystals, hasBigKey, inGame, ownedCrystals, sock]);
-
-       // start game once all required crystals have been acquired
-       React.useEffect(() => {
-               if (!applicable || hasBigKey || ownedCrystals !== gtCrystals || hasEntered) return;
-               controls.current.onStart();
-               const updateDungeon = () => {
-                       sock.current.readWRAM(WRAM_ADDR.CURRENT_DUNGEON, 2, (data) => {
-                               setLastEntrance(data[0] + (data[1] * 256));
-                       });
-               };
-               const timer = setInterval(updateDungeon, 1000);
-               return () => {
-                       clearInterval(timer);
-               };
-       }, [applicable, controls, gtCrystals, hasBigKey, hasEntered, ownedCrystals]);
-
-       // stop game when GT has been entered
-       React.useEffect(() => {
-               if (!applicable || hasBigKey || ownedCrystals !== gtCrystals) return;
-               if (lastEntrance === GT_ENTRANCE_ID) {
-                       controls.current.onStop();
-                       setHasEntered(true);
-               }
-       }, [applicable, controls, gtCrystals, hasBigKey, lastEntrance, ownedCrystals]);
-
-       // watch GT state
-       React.useEffect(() => {
-               if (!applicable || !hasEntered || hasBigKey) return;
-               const updateGTState = () => {
-                       const roomDataStart = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.ROOM_DATA_START;
-                       const roomDataSize = SRAM_ADDR.ROOM_DATA_END - SRAM_ADDR.ROOM_DATA_START;
-                       sock.current.readWRAM(roomDataStart, roomDataSize, (data) => {
-                               const gtState = getGTBasementState(data);
-                               const gtCount = countGTBasementState(gtState);
-                               setBasement(old => {
-                                       const cmp = compareGTBasementState(old.state, gtState);
-                                       if (cmp) {
-                                               return {
-                                                       state: gtState,
-                                                       last: cmp,
-                                                       count: gtCount,
-                                                       torch: cmp === 'torchSeen' ? gtCount : old.torch,
-                                               };
-                                       }
-                                       return old;
-                               });
-                       });
-               };
-               const timer = setInterval(updateGTState, 500);
-               return () => {
-                       clearInterval(timer);
-               };
-       }, [applicable, hasBigKey, hasEntered]);
-
-       React.useEffect(() => {
-               if (!applicable) return;
-               if (hasBigKey) {
-                       const solution = basement.last === 'torch' ? basement.torch : basement.count;
-                       controls.current.onSolve(solution);
-               } else {
-                       const bkAddr = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.INV_START + INV_ADDR.BIG_KEY;
-                       sock.current.readWRAM(bkAddr, 1, (data) => {
-                               setHasBigKey(!!(data[0] & 0x04));
-                       });
-               }
-       }, [applicable, basement, controls, hasBigKey]);
-
-       const statusMsg = React.useMemo(() => {
-               if (!enabled) {
-                       return 'disabled';
-               }
-               if (status.error) {
-                       return 'error';
-               }
-               if (!status.connected) {
-                       return 'disconnected';
-               }
-               if (!status.device) {
-                       return 'no-device';
-               }
-               if (!inGame) {
-                       return 'not-in-game';
-               }
-               if (!applicable) {
-                       return 'not-applicable';
-               }
-               return 'tracking';
-       }, [applicable, enabled, inGame, status]);
-
-       return <div>
-               {['disconnected', 'error', 'no-device'].includes(statusMsg) ?
-                       <Icon.WARNING
-                               className="me-2 text-warning"
-                               size="lg"
-                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
-                       />
-               : null}
-               {['not-applicable', 'not-in-game'].includes(statusMsg) ?
-                       <Icon.INFO
-                               className="me-2 text-info"
-                               size="lg"
-                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
-                       />
-               : null}
-               <Button
-                       className="me-2"
-                       onClick={openSettings}
-                       size="sm"
-                       title={t('snes.settings')}
-                       variant="outline-secondary"
-               >
-                       <Icon.SETTINGS title="" />
-               </Button>
-               <ToggleSwitch
-                       onChange={toggle}
-                       title={t('autoTracking.heading')}
-                       value={enabled}
-               />
-       </div>;
-};
-
-GuessingGameAutoTracking.propTypes = {
-       onSolve: PropTypes.func,
-       onStart: PropTypes.func,
-       onStop: PropTypes.func,
-};
-
-export default GuessingGameAutoTracking;
diff --git a/resources/js/components/twitch-bot/GuessingGameAutoTracking.jsx b/resources/js/components/twitch-bot/GuessingGameAutoTracking.jsx
new file mode 100644 (file)
index 0000000..451c597
--- /dev/null
@@ -0,0 +1,327 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import ToggleSwitch from '../common/ToggleSwitch';
+import {
+       compareGTBasementState,
+       countGTBasementState,
+       getGTBasementState,
+       IN_GAME_MODES,
+       INV_ADDR,
+       RAM_ADDR,
+       SRAM_ADDR,
+       WRAM_ADDR,
+} from '../../helpers/alttp-ram';
+import { useSNES } from '../../hooks/snes';
+
+const GT_TYPES = [
+       0x02, // all dungeons
+       0x03, // defeat ganon
+       0x04, // fast ganon (the default and used for defeat ganon for some reason)
+       0x07, // crystals & bosses
+       0x08, // bosses
+       0x09, // all dungeons, no aga 1
+       0x0B, // completionist
+];
+
+const GT_ENTRANCE_ID = 55;
+
+const GuessingGameAutoTracking = ({ onSolve, onStart, onStop }) => {
+       const [enabled, setEnabled] = React.useState(false);
+       const controls = React.useRef({
+               onSolve,
+               onStart,
+               onStop,
+       });
+
+       const [inGame, setInGame] = React.useState(false);
+       const [seedType, setSeedType] = React.useState(0);
+       const [gtCrystals, setGTCrystals] = React.useState(0);
+       const [ganonType, setGanonType] = React.useState(0);
+       const [freeItemMenu, setFreeItemMenu] = React.useState(0);
+       const [pyramidOpen, setPyramidOpen] = React.useState(false);
+
+       const [ownedCrystals, setOwnedCrystals] = React.useState(0);
+       const [lastEntrance, setLastEntrance] = React.useState(0);
+       const [hasEntered, setHasEntered] = React.useState(false);
+       const [basement, setBasement] = React.useState({
+               state: getGTBasementState(),
+               last: '',
+               count: 0,
+               torch: 0,
+       });
+       const [hasBigKey, setHasBigKey] = React.useState(false);
+
+       const {
+               disable: disableSNES,
+               enable: enableSNES,
+               openSettings,
+               sock,
+               status,
+       } = useSNES();
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               controls.current = {
+                       onSolve,
+                       onStart,
+                       onStop,
+               };
+       }, [onSolve, onStart, onStop]);
+
+       const resetState = React.useCallback(() => {
+               setInGame(false);
+               setSeedType(0);
+               setGTCrystals(0);
+               setGanonType(0);
+               setFreeItemMenu(0);
+
+               setOwnedCrystals(0);
+               setLastEntrance(0);
+               setHasEntered(false);
+               setBasement({
+                       state: getGTBasementState(),
+                       last: '',
+                       count: 0,
+                       torch: 0,
+               });
+               setHasBigKey(false);
+       }, []);
+
+       const enable = React.useCallback(() => {
+               enableSNES();
+               setEnabled(true);
+       }, []);
+
+       const disable = React.useCallback(() => {
+               disableSNES();
+               setEnabled(false);
+               resetState();
+       }, []);
+
+       React.useEffect(() => {
+               const savedSettings = localStorage.getItem('guessingGame.settings');
+               if (savedSettings) {
+                       const settings = JSON.parse(savedSettings);
+                       if (settings.autoTrack) {
+                               enable();
+                       }
+               }
+       }, []);
+
+       const saveSettings = React.useCallback((newSettings) => {
+               const savedSettings = localStorage.getItem('guessingGame.settings');
+               const settings = savedSettings
+                       ? { ...JSON.parse(savedSettings), ...newSettings }
+                       : newSettings;
+               localStorage.setItem('guessingGame.settings', JSON.stringify(settings));
+       }, []);
+
+       const toggle = React.useCallback(() => {
+               if (enabled) {
+                       disable();
+                       saveSettings({ autoTrack: false });
+               } else {
+                       enable();
+                       saveSettings({ autoTrack: true });
+               }
+       }, [enabled]);
+
+       // game mode timer
+       React.useEffect(() => {
+               if (enabled && !status.error && status.connected && status.device) {
+                       const checkInGame = () => {
+                               sock.current.readWRAM(WRAM_ADDR.GAME_MODE, 1, (data) => {
+                                       setInGame(IN_GAME_MODES.includes(data[0]));
+                               });
+                       };
+                       checkInGame();
+                       const timer = setInterval(checkInGame, 5000);
+                       return () => {
+                               clearInterval(timer);
+                       };
+               } else {
+                       setInGame(false);
+               }
+       }, [enabled && !status.error && status.connected && status.device]);
+
+       // refresh static game information
+       React.useEffect(() => {
+               if (!inGame) return;
+               sock.current.readBytes(RAM_ADDR.SEED_TYPE, 1, (data) => {
+                       setSeedType(data[0]);
+               });
+               sock.current.readBytes(RAM_ADDR.GT_CRYSTALS, 1, (data) => {
+                       setGTCrystals(data[0]);
+               });
+               sock.current.readBytes(RAM_ADDR.GANON_TYPE, 1, (data) => {
+                       setGanonType(data[0]);
+               });
+               sock.current.readBytes(RAM_ADDR.FREE_ITEM_MENU, 1, (data) => {
+                       setFreeItemMenu(data[0]);
+               });
+               sock.current.readBytes(RAM_ADDR.INIT_SRAM + SRAM_ADDR.PYRAMID_SCREEN, 1, (data) => {
+                       setPyramidOpen(!!(data[0] & 0x20));
+               });
+       }, [inGame, sock]);
+
+       const applicable = React.useMemo(() => {
+               return !seedType &&
+                       gtCrystals &&
+                       GT_TYPES.includes(ganonType) &&
+                       !pyramidOpen &&
+                       !(freeItemMenu & 0x02);
+       }, [freeItemMenu, ganonType, gtCrystals, pyramidOpen, seedType]);
+
+       // update crystals information
+       React.useEffect(() => {
+               if (!applicable || !inGame || hasBigKey) return;
+               const updateCrystals = () => {
+                       const crAddress = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.INV_START + INV_ADDR.CRYSTALS;
+                       sock.current.readWRAM(crAddress, 1, (data) => {
+                               let owned = 0;
+                               for (let i = 0; i < 7; ++i) {
+                                       if (data[0] & Math.pow(2, i)) {
+                                               ++owned;
+                                       }
+                               }
+                               setOwnedCrystals(owned);
+                       });
+               };
+               // increase frequency for the last
+               const timer = setInterval(updateCrystals, ownedCrystals === gtCrystals - 1 ? 1000 : 15000);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [applicable, gtCrystals, hasBigKey, inGame, ownedCrystals, sock]);
+
+       // start game once all required crystals have been acquired
+       React.useEffect(() => {
+               if (!applicable || hasBigKey || ownedCrystals !== gtCrystals || hasEntered) return;
+               controls.current.onStart();
+               const updateDungeon = () => {
+                       sock.current.readWRAM(WRAM_ADDR.CURRENT_DUNGEON, 2, (data) => {
+                               setLastEntrance(data[0] + (data[1] * 256));
+                       });
+               };
+               const timer = setInterval(updateDungeon, 1000);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [applicable, controls, gtCrystals, hasBigKey, hasEntered, ownedCrystals]);
+
+       // stop game when GT has been entered
+       React.useEffect(() => {
+               if (!applicable || hasBigKey || ownedCrystals !== gtCrystals) return;
+               if (lastEntrance === GT_ENTRANCE_ID) {
+                       controls.current.onStop();
+                       setHasEntered(true);
+               }
+       }, [applicable, controls, gtCrystals, hasBigKey, lastEntrance, ownedCrystals]);
+
+       // watch GT state
+       React.useEffect(() => {
+               if (!applicable || !hasEntered || hasBigKey) return;
+               const updateGTState = () => {
+                       const roomDataStart = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.ROOM_DATA_START;
+                       const roomDataSize = SRAM_ADDR.ROOM_DATA_END - SRAM_ADDR.ROOM_DATA_START;
+                       sock.current.readWRAM(roomDataStart, roomDataSize, (data) => {
+                               const gtState = getGTBasementState(data);
+                               const gtCount = countGTBasementState(gtState);
+                               setBasement(old => {
+                                       const cmp = compareGTBasementState(old.state, gtState);
+                                       if (cmp) {
+                                               return {
+                                                       state: gtState,
+                                                       last: cmp,
+                                                       count: gtCount,
+                                                       torch: cmp === 'torchSeen' ? gtCount : old.torch,
+                                               };
+                                       }
+                                       return old;
+                               });
+                       });
+               };
+               const timer = setInterval(updateGTState, 500);
+               return () => {
+                       clearInterval(timer);
+               };
+       }, [applicable, hasBigKey, hasEntered]);
+
+       React.useEffect(() => {
+               if (!applicable) return;
+               if (hasBigKey) {
+                       const solution = basement.last === 'torch' ? basement.torch : basement.count;
+                       controls.current.onSolve(solution);
+               } else {
+                       const bkAddr = WRAM_ADDR.SAVE_DATA + SRAM_ADDR.INV_START + INV_ADDR.BIG_KEY;
+                       sock.current.readWRAM(bkAddr, 1, (data) => {
+                               setHasBigKey(!!(data[0] & 0x04));
+                       });
+               }
+       }, [applicable, basement, controls, hasBigKey]);
+
+       const statusMsg = React.useMemo(() => {
+               if (!enabled) {
+                       return 'disabled';
+               }
+               if (status.error) {
+                       return 'error';
+               }
+               if (!status.connected) {
+                       return 'disconnected';
+               }
+               if (!status.device) {
+                       return 'no-device';
+               }
+               if (!inGame) {
+                       return 'not-in-game';
+               }
+               if (!applicable) {
+                       return 'not-applicable';
+               }
+               return 'tracking';
+       }, [applicable, enabled, inGame, status]);
+
+       return <div>
+               {['disconnected', 'error', 'no-device'].includes(statusMsg) ?
+                       <Icon.WARNING
+                               className="me-2 text-warning"
+                               size="lg"
+                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
+                       />
+               : null}
+               {['not-applicable', 'not-in-game'].includes(statusMsg) ?
+                       <Icon.INFO
+                               className="me-2 text-info"
+                               size="lg"
+                               title={t(`autoTracking.statusMsg.${statusMsg}`, { device: status.device  })}
+                       />
+               : null}
+               <Button
+                       className="me-2"
+                       onClick={openSettings}
+                       size="sm"
+                       title={t('snes.settings')}
+                       variant="outline-secondary"
+               >
+                       <Icon.SETTINGS title="" />
+               </Button>
+               <ToggleSwitch
+                       onChange={toggle}
+                       title={t('autoTracking.heading')}
+                       value={enabled}
+               />
+       </div>;
+};
+
+GuessingGameAutoTracking.propTypes = {
+       onSolve: PropTypes.func,
+       onStart: PropTypes.func,
+       onStop: PropTypes.func,
+};
+
+export default GuessingGameAutoTracking;
diff --git a/resources/js/components/twitch-bot/GuessingGameControls.js b/resources/js/components/twitch-bot/GuessingGameControls.js
deleted file mode 100644 (file)
index 50a3c3a..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import GuessingGameAutoTracking from './GuessingGameAutoTracking';
-import {
-       hasActiveGuessing,
-       isAcceptingGuesses,
-} from '../../helpers/Channel';
-
-const GuessingGameControls = ({
-       channel,
-       onCancel,
-       onSolve,
-       onStart,
-       onStop,
-}) => {
-       const { t } = useTranslation();
-
-       const solutions = [
-               1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
-               13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
-       ];
-
-       return <div>
-               <div className="d-flex align-items-center justify-content-between mt-3">
-                       <div className="button-bar">
-                               <Button
-                                       onClick={onStart}
-                                       variant={hasActiveGuessing(channel) ? 'success' : 'outline-success'}
-                               >
-                                       {t('button.start')}
-                               </Button>
-                               <Button
-                                       onClick={onStop}
-                                       variant={
-                                               hasActiveGuessing(channel) && isAcceptingGuesses(channel)
-                                               ? 'danger' : 'outline-danger'
-                                       }
-                               >
-                                       {t('button.stop')}
-                               </Button>
-                               <Button
-                                       className="ms-3"
-                                       onClick={onCancel}
-                                       variant={hasActiveGuessing(channel) ? 'secondary' : 'outline-secondary'}
-                               >
-                                       {t('button.cancel')}
-                               </Button>
-                       </div>
-                       <GuessingGameAutoTracking
-                               onSolve={onSolve}
-                               onStart={onStart}
-                               onStop={onStop}
-                       />
-               </div>
-               {hasActiveGuessing(channel) ?
-                       <div className="bkgg-buttons d-grid gap-3 my-3">
-                               {solutions.map(solution =>
-                                       <Button
-                                               key={solution}
-                                               onClick={() => onSolve(solution)}
-                                               size="lg"
-                                               variant="outline-secondary"
-                                       >
-                                               {solution}
-                                       </Button>
-                               )}
-                       </div>
-               : null}
-       </div>;
-};
-
-GuessingGameControls.propTypes = {
-       channel: PropTypes.shape({
-       }),
-       onCancel: PropTypes.func,
-       onSolve: PropTypes.func,
-       onStart: PropTypes.func,
-       onStop: PropTypes.func,
-};
-
-export default GuessingGameControls;
diff --git a/resources/js/components/twitch-bot/GuessingGameControls.jsx b/resources/js/components/twitch-bot/GuessingGameControls.jsx
new file mode 100644 (file)
index 0000000..50a3c3a
--- /dev/null
@@ -0,0 +1,84 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import GuessingGameAutoTracking from './GuessingGameAutoTracking';
+import {
+       hasActiveGuessing,
+       isAcceptingGuesses,
+} from '../../helpers/Channel';
+
+const GuessingGameControls = ({
+       channel,
+       onCancel,
+       onSolve,
+       onStart,
+       onStop,
+}) => {
+       const { t } = useTranslation();
+
+       const solutions = [
+               1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
+               13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
+       ];
+
+       return <div>
+               <div className="d-flex align-items-center justify-content-between mt-3">
+                       <div className="button-bar">
+                               <Button
+                                       onClick={onStart}
+                                       variant={hasActiveGuessing(channel) ? 'success' : 'outline-success'}
+                               >
+                                       {t('button.start')}
+                               </Button>
+                               <Button
+                                       onClick={onStop}
+                                       variant={
+                                               hasActiveGuessing(channel) && isAcceptingGuesses(channel)
+                                               ? 'danger' : 'outline-danger'
+                                       }
+                               >
+                                       {t('button.stop')}
+                               </Button>
+                               <Button
+                                       className="ms-3"
+                                       onClick={onCancel}
+                                       variant={hasActiveGuessing(channel) ? 'secondary' : 'outline-secondary'}
+                               >
+                                       {t('button.cancel')}
+                               </Button>
+                       </div>
+                       <GuessingGameAutoTracking
+                               onSolve={onSolve}
+                               onStart={onStart}
+                               onStop={onStop}
+                       />
+               </div>
+               {hasActiveGuessing(channel) ?
+                       <div className="bkgg-buttons d-grid gap-3 my-3">
+                               {solutions.map(solution =>
+                                       <Button
+                                               key={solution}
+                                               onClick={() => onSolve(solution)}
+                                               size="lg"
+                                               variant="outline-secondary"
+                                       >
+                                               {solution}
+                                       </Button>
+                               )}
+                       </div>
+               : null}
+       </div>;
+};
+
+GuessingGameControls.propTypes = {
+       channel: PropTypes.shape({
+       }),
+       onCancel: PropTypes.func,
+       onSolve: PropTypes.func,
+       onStart: PropTypes.func,
+       onStop: PropTypes.func,
+};
+
+export default GuessingGameControls;
diff --git a/resources/js/components/twitch-bot/GuessingGuess.js b/resources/js/components/twitch-bot/GuessingGuess.js
deleted file mode 100644 (file)
index 61997d3..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Col, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-const GuessingGuess = ({ guess }) => {
-       const { t } = useTranslation();
-
-       return <div className="my-2 p-2 border rounded">
-               <Row>
-                       <Col xs={6}>
-                               <div>{guess.uname}</div>
-                               <div className="text-muted">
-                                       {t('twitchBot.guessingGame.guessTimestamp', {
-                                               timestamp: new Date(guess.created_at),
-                                       })}
-                               </div>
-                       </Col>
-                       <Col xs={6}>
-                               <div className="fs-3 text-end">{guess.guess}</div>
-                       </Col>
-               </Row>
-       </div>;
-};
-
-GuessingGuess.propTypes = {
-       guess: PropTypes.shape({
-               created_at: PropTypes.string,
-               guess: PropTypes.string,
-               uname: PropTypes.string,
-       }),
-};
-
-export default GuessingGuess;
diff --git a/resources/js/components/twitch-bot/GuessingGuess.jsx b/resources/js/components/twitch-bot/GuessingGuess.jsx
new file mode 100644 (file)
index 0000000..61997d3
--- /dev/null
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const GuessingGuess = ({ guess }) => {
+       const { t } = useTranslation();
+
+       return <div className="my-2 p-2 border rounded">
+               <Row>
+                       <Col xs={6}>
+                               <div>{guess.uname}</div>
+                               <div className="text-muted">
+                                       {t('twitchBot.guessingGame.guessTimestamp', {
+                                               timestamp: new Date(guess.created_at),
+                                       })}
+                               </div>
+                       </Col>
+                       <Col xs={6}>
+                               <div className="fs-3 text-end">{guess.guess}</div>
+                       </Col>
+               </Row>
+       </div>;
+};
+
+GuessingGuess.propTypes = {
+       guess: PropTypes.shape({
+               created_at: PropTypes.string,
+               guess: PropTypes.string,
+               uname: PropTypes.string,
+       }),
+};
+
+export default GuessingGuess;
diff --git a/resources/js/components/twitch-bot/GuessingSettingsForm.js b/resources/js/components/twitch-bot/GuessingSettingsForm.js
deleted file mode 100644 (file)
index a465e4e..0000000
+++ /dev/null
@@ -1,425 +0,0 @@
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import i18n from '../../i18n';
-import yup from '../../schema/yup';
-
-const GuessingSettingsForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       touched,
-       values,
-}) => {
-       const { t } = useTranslation();
-
-       return <Form noValidate onSubmit={handleSubmit}>
-               <Row>
-                       <Form.Group as={Col} controlId="gg.points_exact_first" md={6}>
-                               <Form.Label>{t('twitchBot.guessingGame.pointsExactFirst')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.points_exact_first && errors.points_exact_first)}
-                                       max="5"
-                                       min="1"
-                                       name="points_exact_first"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       step="1"
-                                       type="number"
-                                       value={values.points_exact_first || 0}
-                               />
-                               {touched.points_exact_first && errors.points_exact_first ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.points_exact_first)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group as={Col} controlId="gg.points_exact_other" md={6}>
-                               <Form.Label>{t('twitchBot.guessingGame.pointsExactOther')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.points_exact_other && errors.points_exact_other)}
-                                       max="5"
-                                       min="0"
-                                       name="points_exact_other"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       step="1"
-                                       type="number"
-                                       value={values.points_exact_other || 0}
-                               />
-                               {touched.points_exact_other && errors.points_exact_other ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.points_exact_other)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group as={Col} controlId="gg.points_close_first" md={6}>
-                               <Form.Label>{t('twitchBot.guessingGame.pointsCloseFirst')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.points_close_first && errors.points_close_first)}
-                                       max="5"
-                                       min="0"
-                                       name="points_close_first"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       step="0.5"
-                                       type="number"
-                                       value={values.points_close_first || 0}
-                               />
-                               {touched.points_close_first && errors.points_close_first ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.points_close_first)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group as={Col} controlId="gg.points_close_other" md={6}>
-                               <Form.Label>{t('twitchBot.guessingGame.pointsCloseOther')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.points_close_other && errors.points_close_other)}
-                                       max="5"
-                                       min="0"
-                                       name="points_close_other"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       step="0.5"
-                                       type="number"
-                                       value={values.points_close_other || 0}
-                               />
-                               {touched.points_close_other && errors.points_close_other ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.points_close_other)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group as={Col} controlId="gg.points_close_max" md={6}>
-                               <Form.Label>{t('twitchBot.guessingGame.pointsCloseMax')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.points_close_max && errors.points_close_max)}
-                                       min="0"
-                                       name="points_close_max"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       step="1"
-                                       type="number"
-                                       value={values.points_close_max || 0}
-                               />
-                               {touched.points_close_max && errors.points_close_max ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.points_close_max)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-                       <Form.Group as={Col} controlId="gg.leaderboard_type" md={6}>
-                               <Form.Label>{t('twitchBot.guessingGame.leaderboardType')}</Form.Label>
-                               <Form.Select
-                                       isInvalid={!!(touched.leaderboard_type && errors.leaderboard_type)}
-                                       name="leaderboard_type"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       value={values.leaderboard_type || 'all'}
-                               >
-                                       {['all', 'year', '365', 'month', '30'].map(type =>
-                                               <option key={type} value={type}>
-                                                       {t(`twitchBot.guessingGame.leaderboardTypes.${type}`)}
-                                               </option>
-                                       )}
-                               </Form.Select>
-                               {touched.leaderboard_type && errors.leaderboard_type ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {t(errors.leaderboard_type)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-               <Form.Group controlId="gg.start_message">
-                       <Form.Label>{t('twitchBot.guessingGame.startMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.start_message && errors.start_message)}
-                               name="start_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.start_message || ''}
-                       />
-                       {touched.start_message && errors.start_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.start_message)}
-                               </Form.Control.Feedback>
-                       : null}
-               </Form.Group>
-               <Form.Group controlId="gg.stop_message">
-                       <Form.Label>{t('twitchBot.guessingGame.stopMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.stop_message && errors.stop_message)}
-                               name="stop_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.stop_message || ''}
-                       />
-                       {touched.stop_message && errors.stop_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.stop_message)}
-                               </Form.Control.Feedback>
-                       : null}
-               </Form.Group>
-               <Form.Group controlId="gg.winners_message">
-                       <Form.Label>{t('twitchBot.guessingGame.winnersMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.winners_message && errors.winners_message)}
-                               name="winners_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.winners_message || ''}
-                       />
-                       {touched.winners_message && errors.winners_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.winners_message)}
-                               </Form.Control.Feedback>
-                       :
-                               <Form.Text muted>
-                                       {t('twitchBot.guessingGame.winnersMessageHint')}
-                               </Form.Text>
-                       }
-               </Form.Group>
-               <Form.Group controlId="gg.close_winners_message">
-                       <Form.Label>{t('twitchBot.guessingGame.closeWinnersMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.close_winners_message && errors.close_winners_message)}
-                               name="close_winners_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.close_winners_message || ''}
-                       />
-                       {touched.close_winners_message && errors.close_winners_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.close_winners_message)}
-                               </Form.Control.Feedback>
-                       :
-                               <Form.Text muted>
-                                       {t('twitchBot.guessingGame.closeWinnersMessageHint')}
-                               </Form.Text>
-                       }
-               </Form.Group>
-               <Form.Group controlId="gg.no_winners_message">
-                       <Form.Label>{t('twitchBot.guessingGame.noWinnersMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.no_winners_message && errors.no_winners_message)}
-                               name="no_winners_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.no_winners_message || ''}
-                       />
-                       {touched.no_winners_message && errors.no_winners_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.no_winners_message)}
-                               </Form.Control.Feedback>
-                       : null}
-               </Form.Group>
-               <Form.Group controlId="gg.cancel_message">
-                       <Form.Label>{t('twitchBot.guessingGame.cancelMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.cancel_message && errors.cancel_message)}
-                               name="cancel_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.cancel_message || ''}
-                       />
-                       {touched.cancel_message && errors.cancel_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.cancel_message)}
-                               </Form.Control.Feedback>
-                       : null}
-               </Form.Group>
-               <Form.Group controlId="gg.invalid_solution_message">
-                       <Form.Label>{t('twitchBot.guessingGame.invalidSolutionMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.invalid_solution_message && errors.invalid_solution_message)}
-                               name="invalid_solution_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.invalid_solution_message || ''}
-                       />
-                       {touched.invalid_solution_message && errors.invalid_solution_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.invalid_solution_message)}
-                               </Form.Control.Feedback>
-                       : null}
-               </Form.Group>
-               <Form.Group controlId="gg.active_message">
-                       <Form.Label>{t('twitchBot.guessingGame.activeMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.active_message && errors.active_message)}
-                               name="active_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.active_message || ''}
-                       />
-                       {touched.active_message && errors.active_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.active_message)}
-                               </Form.Control.Feedback>
-                       : null}
-               </Form.Group>
-               <Form.Group controlId="gg.not_active_message">
-                       <Form.Label>{t('twitchBot.guessingGame.notActiveMessage')}</Form.Label>
-                       <Form.Control
-                               isInvalid={!!(touched.not_active_message && errors.not_active_message)}
-                               name="not_active_message"
-                               onBlur={handleBlur}
-                               onChange={handleChange}
-                               type="text"
-                               value={values.not_active_message || ''}
-                       />
-                       {touched.not_active_message && errors.not_active_message ?
-                               <Form.Control.Feedback type="invalid">
-                                       {t(errors.not_active_message)}
-                               </Form.Control.Feedback>
-                       : null}
-               </Form.Group>
-               <div className="button-bar mt-3">
-                       {onCancel ?
-                               <Button onClick={onCancel} variant="secondary">
-                                       {t('button.cancel')}
-                               </Button>
-                       : null}
-                       <Button type="submit" variant="primary">
-                               {t('button.save')}
-                       </Button>
-               </div>
-       </Form>;
-};
-
-GuessingSettingsForm.propTypes = {
-       errors: PropTypes.shape({
-               active_message: PropTypes.string,
-               cancel_message: PropTypes.string,
-               close_winners_message: PropTypes.string,
-               invalid_solution_message: PropTypes.string,
-               leaderboard_type: PropTypes.string,
-               name: PropTypes.string,
-               no_winners_message: PropTypes.string,
-               not_active_message: PropTypes.string,
-               points_close_first: PropTypes.string,
-               points_close_max: PropTypes.string,
-               points_close_other: PropTypes.string,
-               points_exact_first: PropTypes.string,
-               points_exact_other: PropTypes.string,
-               start_message: PropTypes.string,
-               stop_message: PropTypes.string,
-               winners_message: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       name: PropTypes.string,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               active_message: PropTypes.bool,
-               cancel_message: PropTypes.bool,
-               close_winners_message: PropTypes.bool,
-               invalid_solution_message: PropTypes.bool,
-               leaderboard_type: PropTypes.bool,
-               name: PropTypes.bool,
-               no_winners_message: PropTypes.bool,
-               not_active_message: PropTypes.bool,
-               points_close_first: PropTypes.bool,
-               points_close_max: PropTypes.bool,
-               points_close_other: PropTypes.bool,
-               points_exact_first: PropTypes.bool,
-               points_exact_other: PropTypes.bool,
-               start_message: PropTypes.bool,
-               stop_message: PropTypes.bool,
-               winners_message: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               active_message: PropTypes.string,
-               cancel_message: PropTypes.string,
-               close_winners_message: PropTypes.string,
-               invalid_solution_message: PropTypes.string,
-               leaderboard_type: PropTypes.string,
-               name: PropTypes.string,
-               no_winners_message: PropTypes.string,
-               not_active_message: PropTypes.string,
-               points_close_first: PropTypes.number,
-               points_close_max: PropTypes.number,
-               points_close_other: PropTypes.number,
-               points_exact_first: PropTypes.number,
-               points_exact_other: PropTypes.number,
-               start_message: PropTypes.string,
-               stop_message: PropTypes.string,
-               winners_message: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'GuessingSettingsForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { setErrors } = actions;
-               const { onSubmit } = actions.props;
-               try {
-                       await onSubmit(values);
-               } catch (e) {
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ name, settings }) => {
-               const getNumericValue = (s, n, d) => s && Object.prototype.hasOwnProperty.call(s, n)
-                       ? s[n] : d;
-               const getStringValue = (s, n, d) => s && Object.prototype.hasOwnProperty.call(s, n)
-                       ? s[n] : i18n.t(`twitchBot.guessingGame.default${d}`);
-               return {
-                       active_message: getStringValue(settings, 'active_message', 'ActiveMessage'),
-                       cancel_message: getStringValue(settings, 'cancel_message', 'CancelMessage'),
-                       close_winners_message:
-                               getStringValue(settings, 'close_winners_message', 'CloseWinnersMessage'),
-                       invalid_solution_message:
-                               getStringValue(settings, 'invalid_solution_message', 'InvalidSolutionMessage'),
-                       leaderboard_type: (settings && settings.leaderboard_type) || 'all',
-                       name: name || '',
-                       no_winners_message: getStringValue(settings, 'no_winners_message', 'NoWinnersMessage'),
-                       not_active_message: getStringValue(settings, 'not_active_message', 'NotActiveMessage'),
-                       points_close_first: getNumericValue(settings, 'points_close_first', 1),
-                       points_close_max: getNumericValue(settings, 'points_close_max', 3),
-                       points_close_other: getNumericValue(settings, 'points_close_other', 1),
-                       points_exact_first: getNumericValue(settings, 'points_exact_first', 1),
-                       points_exact_other: getNumericValue(settings, 'points_exact_other', 1),
-                       start_message: getStringValue(settings, 'start_message', 'StartMessage'),
-                       stop_message: getStringValue(settings, 'stop_message', 'StopMessage'),
-                       winners_message: getStringValue(settings, 'winners_message', 'WinnersMessage'),
-               };
-       },
-       validationSchema: yup.object().shape({
-               active_message: yup.string(),
-               cancel_message: yup.string(),
-               close_winners_message: yup.string(),
-               leaderboard_type: yup.string(),
-               invalid_solution_message: yup.string(),
-               name: yup.string().required(),
-               no_winners_message: yup.string(),
-               not_active_message: yup.string(),
-               points_close_first: yup.number(),
-               points_close_max: yup.number(),
-               points_close_other: yup.number(),
-               points_exact_first: yup.number(),
-               points_exact_other: yup.number(),
-               start_message: yup.string(),
-               stop_message: yup.string(),
-               winners_message: yup.string(),
-       }),
-})(GuessingSettingsForm);
diff --git a/resources/js/components/twitch-bot/GuessingSettingsForm.jsx b/resources/js/components/twitch-bot/GuessingSettingsForm.jsx
new file mode 100644 (file)
index 0000000..a465e4e
--- /dev/null
@@ -0,0 +1,425 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import i18n from '../../i18n';
+import yup from '../../schema/yup';
+
+const GuessingSettingsForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <Row>
+                       <Form.Group as={Col} controlId="gg.points_exact_first" md={6}>
+                               <Form.Label>{t('twitchBot.guessingGame.pointsExactFirst')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.points_exact_first && errors.points_exact_first)}
+                                       max="5"
+                                       min="1"
+                                       name="points_exact_first"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       step="1"
+                                       type="number"
+                                       value={values.points_exact_first || 0}
+                               />
+                               {touched.points_exact_first && errors.points_exact_first ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.points_exact_first)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} controlId="gg.points_exact_other" md={6}>
+                               <Form.Label>{t('twitchBot.guessingGame.pointsExactOther')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.points_exact_other && errors.points_exact_other)}
+                                       max="5"
+                                       min="0"
+                                       name="points_exact_other"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       step="1"
+                                       type="number"
+                                       value={values.points_exact_other || 0}
+                               />
+                               {touched.points_exact_other && errors.points_exact_other ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.points_exact_other)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} controlId="gg.points_close_first" md={6}>
+                               <Form.Label>{t('twitchBot.guessingGame.pointsCloseFirst')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.points_close_first && errors.points_close_first)}
+                                       max="5"
+                                       min="0"
+                                       name="points_close_first"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       step="0.5"
+                                       type="number"
+                                       value={values.points_close_first || 0}
+                               />
+                               {touched.points_close_first && errors.points_close_first ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.points_close_first)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} controlId="gg.points_close_other" md={6}>
+                               <Form.Label>{t('twitchBot.guessingGame.pointsCloseOther')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.points_close_other && errors.points_close_other)}
+                                       max="5"
+                                       min="0"
+                                       name="points_close_other"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       step="0.5"
+                                       type="number"
+                                       value={values.points_close_other || 0}
+                               />
+                               {touched.points_close_other && errors.points_close_other ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.points_close_other)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} controlId="gg.points_close_max" md={6}>
+                               <Form.Label>{t('twitchBot.guessingGame.pointsCloseMax')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.points_close_max && errors.points_close_max)}
+                                       min="0"
+                                       name="points_close_max"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       step="1"
+                                       type="number"
+                                       value={values.points_close_max || 0}
+                               />
+                               {touched.points_close_max && errors.points_close_max ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.points_close_max)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} controlId="gg.leaderboard_type" md={6}>
+                               <Form.Label>{t('twitchBot.guessingGame.leaderboardType')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.leaderboard_type && errors.leaderboard_type)}
+                                       name="leaderboard_type"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.leaderboard_type || 'all'}
+                               >
+                                       {['all', 'year', '365', 'month', '30'].map(type =>
+                                               <option key={type} value={type}>
+                                                       {t(`twitchBot.guessingGame.leaderboardTypes.${type}`)}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.leaderboard_type && errors.leaderboard_type ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.leaderboard_type)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+               <Form.Group controlId="gg.start_message">
+                       <Form.Label>{t('twitchBot.guessingGame.startMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.start_message && errors.start_message)}
+                               name="start_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.start_message || ''}
+                       />
+                       {touched.start_message && errors.start_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.start_message)}
+                               </Form.Control.Feedback>
+                       : null}
+               </Form.Group>
+               <Form.Group controlId="gg.stop_message">
+                       <Form.Label>{t('twitchBot.guessingGame.stopMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.stop_message && errors.stop_message)}
+                               name="stop_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.stop_message || ''}
+                       />
+                       {touched.stop_message && errors.stop_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.stop_message)}
+                               </Form.Control.Feedback>
+                       : null}
+               </Form.Group>
+               <Form.Group controlId="gg.winners_message">
+                       <Form.Label>{t('twitchBot.guessingGame.winnersMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.winners_message && errors.winners_message)}
+                               name="winners_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.winners_message || ''}
+                       />
+                       {touched.winners_message && errors.winners_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.winners_message)}
+                               </Form.Control.Feedback>
+                       :
+                               <Form.Text muted>
+                                       {t('twitchBot.guessingGame.winnersMessageHint')}
+                               </Form.Text>
+                       }
+               </Form.Group>
+               <Form.Group controlId="gg.close_winners_message">
+                       <Form.Label>{t('twitchBot.guessingGame.closeWinnersMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.close_winners_message && errors.close_winners_message)}
+                               name="close_winners_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.close_winners_message || ''}
+                       />
+                       {touched.close_winners_message && errors.close_winners_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.close_winners_message)}
+                               </Form.Control.Feedback>
+                       :
+                               <Form.Text muted>
+                                       {t('twitchBot.guessingGame.closeWinnersMessageHint')}
+                               </Form.Text>
+                       }
+               </Form.Group>
+               <Form.Group controlId="gg.no_winners_message">
+                       <Form.Label>{t('twitchBot.guessingGame.noWinnersMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.no_winners_message && errors.no_winners_message)}
+                               name="no_winners_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.no_winners_message || ''}
+                       />
+                       {touched.no_winners_message && errors.no_winners_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.no_winners_message)}
+                               </Form.Control.Feedback>
+                       : null}
+               </Form.Group>
+               <Form.Group controlId="gg.cancel_message">
+                       <Form.Label>{t('twitchBot.guessingGame.cancelMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.cancel_message && errors.cancel_message)}
+                               name="cancel_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.cancel_message || ''}
+                       />
+                       {touched.cancel_message && errors.cancel_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.cancel_message)}
+                               </Form.Control.Feedback>
+                       : null}
+               </Form.Group>
+               <Form.Group controlId="gg.invalid_solution_message">
+                       <Form.Label>{t('twitchBot.guessingGame.invalidSolutionMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.invalid_solution_message && errors.invalid_solution_message)}
+                               name="invalid_solution_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.invalid_solution_message || ''}
+                       />
+                       {touched.invalid_solution_message && errors.invalid_solution_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.invalid_solution_message)}
+                               </Form.Control.Feedback>
+                       : null}
+               </Form.Group>
+               <Form.Group controlId="gg.active_message">
+                       <Form.Label>{t('twitchBot.guessingGame.activeMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.active_message && errors.active_message)}
+                               name="active_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.active_message || ''}
+                       />
+                       {touched.active_message && errors.active_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.active_message)}
+                               </Form.Control.Feedback>
+                       : null}
+               </Form.Group>
+               <Form.Group controlId="gg.not_active_message">
+                       <Form.Label>{t('twitchBot.guessingGame.notActiveMessage')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.not_active_message && errors.not_active_message)}
+                               name="not_active_message"
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.not_active_message || ''}
+                       />
+                       {touched.not_active_message && errors.not_active_message ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.not_active_message)}
+                               </Form.Control.Feedback>
+                       : null}
+               </Form.Group>
+               <div className="button-bar mt-3">
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.cancel')}
+                               </Button>
+                       : null}
+                       <Button type="submit" variant="primary">
+                               {t('button.save')}
+                       </Button>
+               </div>
+       </Form>;
+};
+
+GuessingSettingsForm.propTypes = {
+       errors: PropTypes.shape({
+               active_message: PropTypes.string,
+               cancel_message: PropTypes.string,
+               close_winners_message: PropTypes.string,
+               invalid_solution_message: PropTypes.string,
+               leaderboard_type: PropTypes.string,
+               name: PropTypes.string,
+               no_winners_message: PropTypes.string,
+               not_active_message: PropTypes.string,
+               points_close_first: PropTypes.string,
+               points_close_max: PropTypes.string,
+               points_close_other: PropTypes.string,
+               points_exact_first: PropTypes.string,
+               points_exact_other: PropTypes.string,
+               start_message: PropTypes.string,
+               stop_message: PropTypes.string,
+               winners_message: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       name: PropTypes.string,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               active_message: PropTypes.bool,
+               cancel_message: PropTypes.bool,
+               close_winners_message: PropTypes.bool,
+               invalid_solution_message: PropTypes.bool,
+               leaderboard_type: PropTypes.bool,
+               name: PropTypes.bool,
+               no_winners_message: PropTypes.bool,
+               not_active_message: PropTypes.bool,
+               points_close_first: PropTypes.bool,
+               points_close_max: PropTypes.bool,
+               points_close_other: PropTypes.bool,
+               points_exact_first: PropTypes.bool,
+               points_exact_other: PropTypes.bool,
+               start_message: PropTypes.bool,
+               stop_message: PropTypes.bool,
+               winners_message: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               active_message: PropTypes.string,
+               cancel_message: PropTypes.string,
+               close_winners_message: PropTypes.string,
+               invalid_solution_message: PropTypes.string,
+               leaderboard_type: PropTypes.string,
+               name: PropTypes.string,
+               no_winners_message: PropTypes.string,
+               not_active_message: PropTypes.string,
+               points_close_first: PropTypes.number,
+               points_close_max: PropTypes.number,
+               points_close_other: PropTypes.number,
+               points_exact_first: PropTypes.number,
+               points_exact_other: PropTypes.number,
+               start_message: PropTypes.string,
+               stop_message: PropTypes.string,
+               winners_message: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'GuessingSettingsForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { setErrors } = actions;
+               const { onSubmit } = actions.props;
+               try {
+                       await onSubmit(values);
+               } catch (e) {
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ name, settings }) => {
+               const getNumericValue = (s, n, d) => s && Object.prototype.hasOwnProperty.call(s, n)
+                       ? s[n] : d;
+               const getStringValue = (s, n, d) => s && Object.prototype.hasOwnProperty.call(s, n)
+                       ? s[n] : i18n.t(`twitchBot.guessingGame.default${d}`);
+               return {
+                       active_message: getStringValue(settings, 'active_message', 'ActiveMessage'),
+                       cancel_message: getStringValue(settings, 'cancel_message', 'CancelMessage'),
+                       close_winners_message:
+                               getStringValue(settings, 'close_winners_message', 'CloseWinnersMessage'),
+                       invalid_solution_message:
+                               getStringValue(settings, 'invalid_solution_message', 'InvalidSolutionMessage'),
+                       leaderboard_type: (settings && settings.leaderboard_type) || 'all',
+                       name: name || '',
+                       no_winners_message: getStringValue(settings, 'no_winners_message', 'NoWinnersMessage'),
+                       not_active_message: getStringValue(settings, 'not_active_message', 'NotActiveMessage'),
+                       points_close_first: getNumericValue(settings, 'points_close_first', 1),
+                       points_close_max: getNumericValue(settings, 'points_close_max', 3),
+                       points_close_other: getNumericValue(settings, 'points_close_other', 1),
+                       points_exact_first: getNumericValue(settings, 'points_exact_first', 1),
+                       points_exact_other: getNumericValue(settings, 'points_exact_other', 1),
+                       start_message: getStringValue(settings, 'start_message', 'StartMessage'),
+                       stop_message: getStringValue(settings, 'stop_message', 'StopMessage'),
+                       winners_message: getStringValue(settings, 'winners_message', 'WinnersMessage'),
+               };
+       },
+       validationSchema: yup.object().shape({
+               active_message: yup.string(),
+               cancel_message: yup.string(),
+               close_winners_message: yup.string(),
+               leaderboard_type: yup.string(),
+               invalid_solution_message: yup.string(),
+               name: yup.string().required(),
+               no_winners_message: yup.string(),
+               not_active_message: yup.string(),
+               points_close_first: yup.number(),
+               points_close_max: yup.number(),
+               points_close_other: yup.number(),
+               points_exact_first: yup.number(),
+               points_exact_other: yup.number(),
+               start_message: yup.string(),
+               stop_message: yup.string(),
+               winners_message: yup.string(),
+       }),
+})(GuessingSettingsForm);
diff --git a/resources/js/components/twitch-bot/GuessingWinner.js b/resources/js/components/twitch-bot/GuessingWinner.js
deleted file mode 100644 (file)
index 64ecc96..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Col, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-const GuessingWinner = ({ winner }) => {
-       const { t } = useTranslation();
-
-       const classNames = ['guessing-game-winner', 'my-2', 'p-2', 'border', 'rounded'];
-       if (!winner.score) {
-               classNames.push('no-points');
-       }
-
-       return <div className={classNames.join(' ')}>
-               <Row>
-                       <Col xs={6}>
-                               <div>{winner.uname}</div>
-                               <div>{t(
-                                       'twitchBot.guessingGame.winnerScore',
-                                       { count: winner.score, score: winner.score },
-                               )}</div>
-                       </Col>
-                       <Col xs={6}>
-                               <div className="fs-3 text-end">{winner.guess}</div>
-                       </Col>
-               </Row>
-       </div>;
-};
-
-GuessingWinner.propTypes = {
-       winner: PropTypes.shape({
-               guess: PropTypes.string,
-               score: PropTypes.number,
-               uname: PropTypes.string,
-       }),
-};
-
-export default GuessingWinner;
diff --git a/resources/js/components/twitch-bot/GuessingWinner.jsx b/resources/js/components/twitch-bot/GuessingWinner.jsx
new file mode 100644 (file)
index 0000000..64ecc96
--- /dev/null
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const GuessingWinner = ({ winner }) => {
+       const { t } = useTranslation();
+
+       const classNames = ['guessing-game-winner', 'my-2', 'p-2', 'border', 'rounded'];
+       if (!winner.score) {
+               classNames.push('no-points');
+       }
+
+       return <div className={classNames.join(' ')}>
+               <Row>
+                       <Col xs={6}>
+                               <div>{winner.uname}</div>
+                               <div>{t(
+                                       'twitchBot.guessingGame.winnerScore',
+                                       { count: winner.score, score: winner.score },
+                               )}</div>
+                       </Col>
+                       <Col xs={6}>
+                               <div className="fs-3 text-end">{winner.guess}</div>
+                       </Col>
+               </Row>
+       </div>;
+};
+
+GuessingWinner.propTypes = {
+       winner: PropTypes.shape({
+               guess: PropTypes.string,
+               score: PropTypes.number,
+               uname: PropTypes.string,
+       }),
+};
+
+export default GuessingWinner;
diff --git a/resources/js/components/users/Box.js b/resources/js/components/users/Box.js
deleted file mode 100644 (file)
index c0d011b..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
-
-import { getAvatarUrl, getUserName } from '../../helpers/User';
-import i18n from '../../i18n';
-
-const Box = ({ discriminator, noLink, user }) => {
-       const navigate = useNavigate();
-
-       if (!user) {
-               return <span>{i18n.t('general.anonymous')}</span>;
-       }
-
-       const content = <>
-               <img alt="" src={getAvatarUrl(user)} />
-               <span>{discriminator ? user.username : getUserName(user)}</span>
-               {discriminator && user.discriminator && user.discriminator !== '0' ?
-                       <span className="text-muted">
-                               {'#'}
-                               {user.discriminator}
-                       </span>
-               : null}
-       </>;
-
-       if (noLink) {
-               return <span className="user-box">{content}</span>;
-       }
-
-       return <Button
-               className="user-box"
-               onClick={() => navigate(`/users/${user.id}`)}
-               variant="link"
-       >
-               {content}
-       </Button>;
-};
-
-Box.propTypes = {
-       discriminator: PropTypes.bool,
-       noLink: PropTypes.bool,
-       user: PropTypes.shape({
-               discriminator: PropTypes.string,
-               id: PropTypes.string,
-               nickname: PropTypes.string,
-               username: PropTypes.string,
-       }),
-};
-
-export default withTranslation()(Box);
diff --git a/resources/js/components/users/Box.jsx b/resources/js/components/users/Box.jsx
new file mode 100644 (file)
index 0000000..c0d011b
--- /dev/null
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+
+import { getAvatarUrl, getUserName } from '../../helpers/User';
+import i18n from '../../i18n';
+
+const Box = ({ discriminator, noLink, user }) => {
+       const navigate = useNavigate();
+
+       if (!user) {
+               return <span>{i18n.t('general.anonymous')}</span>;
+       }
+
+       const content = <>
+               <img alt="" src={getAvatarUrl(user)} />
+               <span>{discriminator ? user.username : getUserName(user)}</span>
+               {discriminator && user.discriminator && user.discriminator !== '0' ?
+                       <span className="text-muted">
+                               {'#'}
+                               {user.discriminator}
+                       </span>
+               : null}
+       </>;
+
+       if (noLink) {
+               return <span className="user-box">{content}</span>;
+       }
+
+       return <Button
+               className="user-box"
+               onClick={() => navigate(`/users/${user.id}`)}
+               variant="link"
+       >
+               {content}
+       </Button>;
+};
+
+Box.propTypes = {
+       discriminator: PropTypes.bool,
+       noLink: PropTypes.bool,
+       user: PropTypes.shape({
+               discriminator: PropTypes.string,
+               id: PropTypes.string,
+               nickname: PropTypes.string,
+               username: PropTypes.string,
+       }),
+};
+
+export default withTranslation()(Box);
diff --git a/resources/js/components/users/EditNicknameButton.js b/resources/js/components/users/EditNicknameButton.js
deleted file mode 100644 (file)
index 3e39b3a..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import EditNicknameDialog from './EditNicknameDialog';
-import Icon from '../common/Icon';
-import { mayEditNickname } from '../../helpers/permissions';
-import { useUser } from '../../hooks/user';
-
-const EditNicknameButton = ({ user }) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       const { t } = useTranslation();
-       const { user: authUser } = useUser();
-
-       if (mayEditNickname(authUser, user)) {
-               return <>
-                       <EditNicknameDialog
-                               onHide={() => setShowDialog(false)}
-                               show={showDialog}
-                               user={user}
-                       />
-                       <Button
-                               onClick={() => setShowDialog(true)}
-                               title={t('button.edit')}
-                               variant="outline-secondary"
-                       >
-                               <Icon.EDIT title="" />
-                       </Button>
-               </>;
-       }
-       return null;
-};
-
-EditNicknameButton.propTypes = {
-       user: PropTypes.shape({
-       }),
-};
-
-export default EditNicknameButton;
diff --git a/resources/js/components/users/EditNicknameButton.jsx b/resources/js/components/users/EditNicknameButton.jsx
new file mode 100644 (file)
index 0000000..3e39b3a
--- /dev/null
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import EditNicknameDialog from './EditNicknameDialog';
+import Icon from '../common/Icon';
+import { mayEditNickname } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const EditNicknameButton = ({ user }) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       const { t } = useTranslation();
+       const { user: authUser } = useUser();
+
+       if (mayEditNickname(authUser, user)) {
+               return <>
+                       <EditNicknameDialog
+                               onHide={() => setShowDialog(false)}
+                               show={showDialog}
+                               user={user}
+                       />
+                       <Button
+                               onClick={() => setShowDialog(true)}
+                               title={t('button.edit')}
+                               variant="outline-secondary"
+                       >
+                               <Icon.EDIT title="" />
+                       </Button>
+               </>;
+       }
+       return null;
+};
+
+EditNicknameButton.propTypes = {
+       user: PropTypes.shape({
+       }),
+};
+
+export default EditNicknameButton;
diff --git a/resources/js/components/users/EditNicknameDialog.js b/resources/js/components/users/EditNicknameDialog.js
deleted file mode 100644 (file)
index dbc9cbf..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import EditNicknameForm from './EditNicknameForm';
-import i18n from '../../i18n';
-
-const EditNicknameDialog = ({
-       onHide,
-       show,
-       user,
-}) =>
-<Modal className="edit-stream-link-dialog" onHide={onHide} show={show}>
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t('users.editNickname')}
-               </Modal.Title>
-       </Modal.Header>
-       <EditNicknameForm
-               onCancel={onHide}
-               user={user}
-       />
-</Modal>;
-
-EditNicknameDialog.propTypes = {
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-       user: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(EditNicknameDialog);
diff --git a/resources/js/components/users/EditNicknameDialog.jsx b/resources/js/components/users/EditNicknameDialog.jsx
new file mode 100644 (file)
index 0000000..dbc9cbf
--- /dev/null
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import EditNicknameForm from './EditNicknameForm';
+import i18n from '../../i18n';
+
+const EditNicknameDialog = ({
+       onHide,
+       show,
+       user,
+}) =>
+<Modal className="edit-stream-link-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('users.editNickname')}
+               </Modal.Title>
+       </Modal.Header>
+       <EditNicknameForm
+               onCancel={onHide}
+               user={user}
+       />
+</Modal>;
+
+EditNicknameDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+       user: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(EditNicknameDialog);
diff --git a/resources/js/components/users/EditNicknameForm.js b/resources/js/components/users/EditNicknameForm.js
deleted file mode 100644 (file)
index 56176f2..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-import axios from 'axios';
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import i18n from '../../i18n';
-import yup from '../../schema/yup';
-
-const EditStreamLinkForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       touched,
-       user,
-       values,
-}) =>
-<Form noValidate onSubmit={handleSubmit}>
-       <Modal.Body>
-               <Row>
-                       <Form.Group as={Col} controlId="user.nickname">
-                               <Form.Label>{i18n.t('users.nickname')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.nickname && errors.nickname)}
-                                       name="nickname"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       placeholder={user.username}
-                                       type="text"
-                                       value={values.nickname || ''}
-                               />
-                               {touched.nickname && errors.nickname ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {i18n.t(errors.nickname)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-       </Modal.Body>
-       <Modal.Footer>
-               {onCancel ?
-                       <Button onClick={onCancel} variant="secondary">
-                               {i18n.t('button.cancel')}
-                       </Button>
-               : null}
-               <Button type="submit" variant="primary">
-                       {i18n.t('button.save')}
-               </Button>
-       </Modal.Footer>
-</Form>;
-
-EditStreamLinkForm.propTypes = {
-       errors: PropTypes.shape({
-               nickname: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               nickname: PropTypes.bool,
-       }),
-       user: PropTypes.shape({
-               username: PropTypes.string,
-       }),
-       values: PropTypes.shape({
-               nickname: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'NicknameForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { user_id, nickname } = values;
-               const { setErrors } = actions;
-               const { onCancel } = actions.props;
-               try {
-                       await axios.post(`/api/users/${user_id}/setNickname`, {
-                               nickname,
-                       });
-                       toastr.success(i18n.t('users.setNicknameSuccess'));
-                       if (onCancel) {
-                               onCancel();
-                       }
-               } catch (e) {
-                       toastr.error(i18n.t('users.setNicknameError'));
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ user }) => ({
-               user_id: user.id,
-               nickname: user.nickname || '',
-       }),
-       validationSchema: yup.object().shape({
-               nickname: yup.string(),
-       }),
-})(withTranslation()(EditStreamLinkForm));
diff --git a/resources/js/components/users/EditNicknameForm.jsx b/resources/js/components/users/EditNicknameForm.jsx
new file mode 100644 (file)
index 0000000..56176f2
--- /dev/null
@@ -0,0 +1,105 @@
+import axios from 'axios';
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import i18n from '../../i18n';
+import yup from '../../schema/yup';
+
+const EditStreamLinkForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       user,
+       values,
+}) =>
+<Form noValidate onSubmit={handleSubmit}>
+       <Modal.Body>
+               <Row>
+                       <Form.Group as={Col} controlId="user.nickname">
+                               <Form.Label>{i18n.t('users.nickname')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.nickname && errors.nickname)}
+                                       name="nickname"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       placeholder={user.username}
+                                       type="text"
+                                       value={values.nickname || ''}
+                               />
+                               {touched.nickname && errors.nickname ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.nickname)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+       </Modal.Body>
+       <Modal.Footer>
+               {onCancel ?
+                       <Button onClick={onCancel} variant="secondary">
+                               {i18n.t('button.cancel')}
+                       </Button>
+               : null}
+               <Button type="submit" variant="primary">
+                       {i18n.t('button.save')}
+               </Button>
+       </Modal.Footer>
+</Form>;
+
+EditStreamLinkForm.propTypes = {
+       errors: PropTypes.shape({
+               nickname: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               nickname: PropTypes.bool,
+       }),
+       user: PropTypes.shape({
+               username: PropTypes.string,
+       }),
+       values: PropTypes.shape({
+               nickname: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'NicknameForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { user_id, nickname } = values;
+               const { setErrors } = actions;
+               const { onCancel } = actions.props;
+               try {
+                       await axios.post(`/api/users/${user_id}/setNickname`, {
+                               nickname,
+                       });
+                       toastr.success(i18n.t('users.setNicknameSuccess'));
+                       if (onCancel) {
+                               onCancel();
+                       }
+               } catch (e) {
+                       toastr.error(i18n.t('users.setNicknameError'));
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ user }) => ({
+               user_id: user.id,
+               nickname: user.nickname || '',
+       }),
+       validationSchema: yup.object().shape({
+               nickname: yup.string(),
+       }),
+})(withTranslation()(EditStreamLinkForm));
diff --git a/resources/js/components/users/EditStreamLinkButton.js b/resources/js/components/users/EditStreamLinkButton.js
deleted file mode 100644 (file)
index 50fffdb..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { useState } from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-
-import EditStreamLinkDialog from './EditStreamLinkDialog';
-import Icon from '../common/Icon';
-import { mayEditStreamLink } from '../../helpers/permissions';
-import { useUser } from '../../hooks/user';
-
-const EditStreamLinkButton = ({ user }) => {
-       const [showDialog, setShowDialog] = useState(false);
-
-       const { t } = useTranslation();
-       const { user: authUser } = useUser();
-
-       if (mayEditStreamLink(authUser, user)) {
-               return <>
-                       <EditStreamLinkDialog
-                               onHide={() => setShowDialog(false)}
-                               show={showDialog}
-                               user={user}
-                       />
-                       <Button
-                               onClick={() => setShowDialog(true)}
-                               title={t('button.edit')}
-                               variant="outline-secondary"
-                       >
-                               <Icon.EDIT title="" />
-                       </Button>
-               </>;
-       }
-       return null;
-};
-
-EditStreamLinkButton.propTypes = {
-       user: PropTypes.shape({
-       }),
-};
-
-export default EditStreamLinkButton;
diff --git a/resources/js/components/users/EditStreamLinkButton.jsx b/resources/js/components/users/EditStreamLinkButton.jsx
new file mode 100644 (file)
index 0000000..50fffdb
--- /dev/null
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import EditStreamLinkDialog from './EditStreamLinkDialog';
+import Icon from '../common/Icon';
+import { mayEditStreamLink } from '../../helpers/permissions';
+import { useUser } from '../../hooks/user';
+
+const EditStreamLinkButton = ({ user }) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       const { t } = useTranslation();
+       const { user: authUser } = useUser();
+
+       if (mayEditStreamLink(authUser, user)) {
+               return <>
+                       <EditStreamLinkDialog
+                               onHide={() => setShowDialog(false)}
+                               show={showDialog}
+                               user={user}
+                       />
+                       <Button
+                               onClick={() => setShowDialog(true)}
+                               title={t('button.edit')}
+                               variant="outline-secondary"
+                       >
+                               <Icon.EDIT title="" />
+                       </Button>
+               </>;
+       }
+       return null;
+};
+
+EditStreamLinkButton.propTypes = {
+       user: PropTypes.shape({
+       }),
+};
+
+export default EditStreamLinkButton;
diff --git a/resources/js/components/users/EditStreamLinkDialog.js b/resources/js/components/users/EditStreamLinkDialog.js
deleted file mode 100644 (file)
index 34db12e..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Modal } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import EditStreamLinkForm from './EditStreamLinkForm';
-import i18n from '../../i18n';
-
-const EditStreamLinkDialog = ({
-       onHide,
-       show,
-       user,
-}) =>
-<Modal className="edit-stream-link-dialog" onHide={onHide} show={show}>
-       <Modal.Header closeButton>
-               <Modal.Title>
-                       {i18n.t('users.editStreamLink')}
-               </Modal.Title>
-       </Modal.Header>
-       <EditStreamLinkForm
-               onCancel={onHide}
-               user={user}
-       />
-</Modal>;
-
-EditStreamLinkDialog.propTypes = {
-       onHide: PropTypes.func,
-       show: PropTypes.bool,
-       user: PropTypes.shape({
-       }),
-};
-
-export default withTranslation()(EditStreamLinkDialog);
diff --git a/resources/js/components/users/EditStreamLinkDialog.jsx b/resources/js/components/users/EditStreamLinkDialog.jsx
new file mode 100644 (file)
index 0000000..34db12e
--- /dev/null
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import EditStreamLinkForm from './EditStreamLinkForm';
+import i18n from '../../i18n';
+
+const EditStreamLinkDialog = ({
+       onHide,
+       show,
+       user,
+}) =>
+<Modal className="edit-stream-link-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('users.editStreamLink')}
+               </Modal.Title>
+       </Modal.Header>
+       <EditStreamLinkForm
+               onCancel={onHide}
+               user={user}
+       />
+</Modal>;
+
+EditStreamLinkDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+       user: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(EditStreamLinkDialog);
diff --git a/resources/js/components/users/EditStreamLinkForm.js b/resources/js/components/users/EditStreamLinkForm.js
deleted file mode 100644 (file)
index 0bc60a5..0000000
+++ /dev/null
@@ -1,101 +0,0 @@
-import axios from 'axios';
-import { withFormik } from 'formik';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
-import i18n from '../../i18n';
-import yup from '../../schema/yup';
-
-const EditStreamLinkForm = ({
-       errors,
-       handleBlur,
-       handleChange,
-       handleSubmit,
-       onCancel,
-       touched,
-       values,
-}) =>
-<Form noValidate onSubmit={handleSubmit}>
-       <Modal.Body>
-               <Row>
-                       <Form.Group as={Col} controlId="user.stream_link">
-                               <Form.Label>{i18n.t('users.streamLink')}</Form.Label>
-                               <Form.Control
-                                       isInvalid={!!(touched.stream_link && errors.stream_link)}
-                                       name="stream_link"
-                                       onBlur={handleBlur}
-                                       onChange={handleChange}
-                                       placeholder="https://www.twitch.tv/fgfm"
-                                       type="text"
-                                       value={values.stream_link || ''}
-                               />
-                               {touched.stream_link && errors.stream_link ?
-                                       <Form.Control.Feedback type="invalid">
-                                               {i18n.t(errors.stream_link)}
-                                       </Form.Control.Feedback>
-                               : null}
-                       </Form.Group>
-               </Row>
-       </Modal.Body>
-       <Modal.Footer>
-               {onCancel ?
-                       <Button onClick={onCancel} variant="secondary">
-                               {i18n.t('button.cancel')}
-                       </Button>
-               : null}
-               <Button type="submit" variant="primary">
-                       {i18n.t('button.save')}
-               </Button>
-       </Modal.Footer>
-</Form>;
-
-EditStreamLinkForm.propTypes = {
-       errors: PropTypes.shape({
-               stream_link: PropTypes.string,
-       }),
-       handleBlur: PropTypes.func,
-       handleChange: PropTypes.func,
-       handleSubmit: PropTypes.func,
-       onCancel: PropTypes.func,
-       touched: PropTypes.shape({
-               stream_link: PropTypes.bool,
-       }),
-       values: PropTypes.shape({
-               stream_link: PropTypes.string,
-       }),
-};
-
-export default withFormik({
-       displayName: 'StreamLinkForm',
-       enableReinitialize: true,
-       handleSubmit: async (values, actions) => {
-               const { user_id, stream_link } = values;
-               const { setErrors } = actions;
-               const { onCancel } = actions.props;
-               try {
-                       await axios.post(`/api/users/${user_id}/setStreamLink`, {
-                               stream_link,
-                       });
-                       toastr.success(i18n.t('users.setStreamLinkSuccess'));
-                       if (onCancel) {
-                               onCancel();
-                       }
-               } catch (e) {
-                       toastr.error(i18n.t('users.setStreamLinkError'));
-                       if (e.response && e.response.data && e.response.data.errors) {
-                               setErrors(laravelErrorsToFormik(e.response.data.errors));
-                       }
-               }
-       },
-       mapPropsToValues: ({ user }) => ({
-               user_id: user.id,
-               stream_link: user.stream_link || '',
-       }),
-       validationSchema: yup.object().shape({
-               stream_link: yup.string().required().url(),
-       }),
-})(withTranslation()(EditStreamLinkForm));
diff --git a/resources/js/components/users/EditStreamLinkForm.jsx b/resources/js/components/users/EditStreamLinkForm.jsx
new file mode 100644 (file)
index 0000000..0bc60a5
--- /dev/null
@@ -0,0 +1,101 @@
+import axios from 'axios';
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import i18n from '../../i18n';
+import yup from '../../schema/yup';
+
+const EditStreamLinkForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       values,
+}) =>
+<Form noValidate onSubmit={handleSubmit}>
+       <Modal.Body>
+               <Row>
+                       <Form.Group as={Col} controlId="user.stream_link">
+                               <Form.Label>{i18n.t('users.streamLink')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.stream_link && errors.stream_link)}
+                                       name="stream_link"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       placeholder="https://www.twitch.tv/fgfm"
+                                       type="text"
+                                       value={values.stream_link || ''}
+                               />
+                               {touched.stream_link && errors.stream_link ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.stream_link)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+       </Modal.Body>
+       <Modal.Footer>
+               {onCancel ?
+                       <Button onClick={onCancel} variant="secondary">
+                               {i18n.t('button.cancel')}
+                       </Button>
+               : null}
+               <Button type="submit" variant="primary">
+                       {i18n.t('button.save')}
+               </Button>
+       </Modal.Footer>
+</Form>;
+
+EditStreamLinkForm.propTypes = {
+       errors: PropTypes.shape({
+               stream_link: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               stream_link: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               stream_link: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'StreamLinkForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { user_id, stream_link } = values;
+               const { setErrors } = actions;
+               const { onCancel } = actions.props;
+               try {
+                       await axios.post(`/api/users/${user_id}/setStreamLink`, {
+                               stream_link,
+                       });
+                       toastr.success(i18n.t('users.setStreamLinkSuccess'));
+                       if (onCancel) {
+                               onCancel();
+                       }
+               } catch (e) {
+                       toastr.error(i18n.t('users.setStreamLinkError'));
+                       if (e.response && e.response.data && e.response.data.errors) {
+                               setErrors(laravelErrorsToFormik(e.response.data.errors));
+                       }
+               }
+       },
+       mapPropsToValues: ({ user }) => ({
+               user_id: user.id,
+               stream_link: user.stream_link || '',
+       }),
+       validationSchema: yup.object().shape({
+               stream_link: yup.string().required().url(),
+       }),
+})(withTranslation()(EditStreamLinkForm));
diff --git a/resources/js/components/users/Participation.js b/resources/js/components/users/Participation.js
deleted file mode 100644 (file)
index 9b7228e..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button, Table } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
-
-import Icon from '../common/Icon';
-import { isRunner } from '../../helpers/Participant';
-import i18n from '../../i18n';
-
-const getIcon = participant => {
-       if (!isRunner(participant)) {
-               return '—';
-       }
-       if (participant.placement === 1) {
-               return <Icon.FIRST_PLACE className="text-gold" size="lg" />;
-       }
-       if (participant.placement === 2) {
-               return <Icon.SECOND_PLACE className="text-silver" size="lg" />;
-       }
-       if (participant.placement === 3) {
-               return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
-       }
-       return participant.placement;
-};
-
-const Participation = ({ user }) => {
-       const navigate = useNavigate();
-
-       if (!user || !user.participation || !user.participation.length) {
-               return <Alert variant="info">
-                       {i18n.t('users.participationEmpty')}
-               </Alert>;
-       }
-       return <Table className="participation align-middle">
-               <thead>
-                       <tr>
-                               <th>{i18n.t('participants.tournament')}</th>
-                               <th>{i18n.t('participants.placement')}</th>
-                               <th>{i18n.t('participants.roles')}</th>
-                       </tr>
-               </thead>
-               <tbody>
-               {user.participation.map(p => <tr key={p.id}>
-                       <td>
-                               <Button
-                                       onClick={() => navigate(`/tournaments/${p.tournament_id}`)}
-                                       variant="link"
-                               >
-                                       {p.tournament.title}
-                               </Button>
-                       </td>
-                       <td>
-                               {getIcon(p)}
-                       {!p.tournament.locked && isRunner(p) ?
-                               <span title={i18n.t('participants.placementSubjectToChange')}> *</span>
-                       : null}
-                       {p.tournament.no_record ?
-                               <span title={i18n.t('tournaments.noRecord')}> â€ </span>
-                       : null}
-                       </td>
-                       <td>
-                               {p.roles ? p.roles.map((role, index) =>
-                                       <span key={role}>
-                                               {index === 0 ? '' : ', '}
-                                               {i18n.t(`participants.roleNames.${role}`)}
-                                       </span>
-                               ) : null}
-                       </td>
-               </tr>)}
-               </tbody>
-       </Table>;
-};
-
-Participation.propTypes = {
-       user: PropTypes.shape({
-               participation: PropTypes.arrayOf(PropTypes.shape({
-                       id: PropTypes.number,
-               })),
-       }),
-};
-
-export default withTranslation()(Participation);
diff --git a/resources/js/components/users/Participation.jsx b/resources/js/components/users/Participation.jsx
new file mode 100644 (file)
index 0000000..9b7228e
--- /dev/null
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Table } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+
+import Icon from '../common/Icon';
+import { isRunner } from '../../helpers/Participant';
+import i18n from '../../i18n';
+
+const getIcon = participant => {
+       if (!isRunner(participant)) {
+               return '—';
+       }
+       if (participant.placement === 1) {
+               return <Icon.FIRST_PLACE className="text-gold" size="lg" />;
+       }
+       if (participant.placement === 2) {
+               return <Icon.SECOND_PLACE className="text-silver" size="lg" />;
+       }
+       if (participant.placement === 3) {
+               return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
+       }
+       return participant.placement;
+};
+
+const Participation = ({ user }) => {
+       const navigate = useNavigate();
+
+       if (!user || !user.participation || !user.participation.length) {
+               return <Alert variant="info">
+                       {i18n.t('users.participationEmpty')}
+               </Alert>;
+       }
+       return <Table className="participation align-middle">
+               <thead>
+                       <tr>
+                               <th>{i18n.t('participants.tournament')}</th>
+                               <th>{i18n.t('participants.placement')}</th>
+                               <th>{i18n.t('participants.roles')}</th>
+                       </tr>
+               </thead>
+               <tbody>
+               {user.participation.map(p => <tr key={p.id}>
+                       <td>
+                               <Button
+                                       onClick={() => navigate(`/tournaments/${p.tournament_id}`)}
+                                       variant="link"
+                               >
+                                       {p.tournament.title}
+                               </Button>
+                       </td>
+                       <td>
+                               {getIcon(p)}
+                       {!p.tournament.locked && isRunner(p) ?
+                               <span title={i18n.t('participants.placementSubjectToChange')}> *</span>
+                       : null}
+                       {p.tournament.no_record ?
+                               <span title={i18n.t('tournaments.noRecord')}> â€ </span>
+                       : null}
+                       </td>
+                       <td>
+                               {p.roles ? p.roles.map((role, index) =>
+                                       <span key={role}>
+                                               {index === 0 ? '' : ', '}
+                                               {i18n.t(`participants.roleNames.${role}`)}
+                                       </span>
+                               ) : null}
+                       </td>
+               </tr>)}
+               </tbody>
+       </Table>;
+};
+
+Participation.propTypes = {
+       user: PropTypes.shape({
+               participation: PropTypes.arrayOf(PropTypes.shape({
+                       id: PropTypes.number,
+               })),
+       }),
+};
+
+export default withTranslation()(Participation);
diff --git a/resources/js/components/users/Profile.js b/resources/js/components/users/Profile.js
deleted file mode 100644 (file)
index b4c4060..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Alert, Button, Col, Container, Row } from 'react-bootstrap';
-import { withTranslation } from 'react-i18next';
-
-import Box from './Box';
-import Records from './Records';
-import EditNicknameButton from './EditNicknameButton';
-import EditStreamLinkButton from './EditStreamLinkButton';
-import Participation from './Participation';
-import Icon from '../common/Icon';
-import i18n from '../../i18n';
-
-const Profile = ({ user }) => <Container>
-       <h1>
-               {user.nickname || user.username}
-               {' '}
-               <EditNicknameButton user={user} />
-       </h1>
-       {user.random_quote && user.random_quote.comment ?
-               <Alert className="quote-alert" variant="dark">
-                       <blockquote className="blockquote mb-0">
-                               {user.random_quote.comment}
-                       </blockquote>
-               </Alert>
-       : null}
-       <Row>
-               <Col md={6} className="mb-5">
-                       <h2>{i18n.t('users.discordTag')}</h2>
-                       <Box discriminator user={user} />
-               </Col>
-               <Col md={6} className="mb-5">
-                       <h2>{i18n.t('users.streamLink')}</h2>
-                       <p>
-                               {user.stream_link ?
-                                       <Button
-                                               href={user.stream_link}
-                                               target="_blank"
-                                               variant="outline-twitch"
-                                       >
-                                               <Icon.STREAM />
-                                               {' '}
-                                               {user.stream_link}
-                                       </Button>
-                               :
-                                       i18n.t('users.noStream')
-                               }
-                               {' '}
-                               <EditStreamLinkButton user={user} />
-                       </p>
-               </Col>
-               <Col md={6} className="mb-5">
-                       <h2>{i18n.t('users.tournamentRecords')}</h2>
-                       <Records
-                               first={user.tournament_first_count}
-                               second={user.tournament_second_count}
-                               third={user.tournament_third_count}
-                       />
-               </Col>
-               <Col md={6} className="mb-5">
-                       <h2>{i18n.t('users.roundRecords')}</h2>
-                       <Records
-                               first={user.round_first_count}
-                               second={user.round_second_count}
-                               third={user.round_third_count}
-                       />
-               </Col>
-               <Col md={12} className="mb-5">
-                       <h2>{i18n.t('users.tournaments')}</h2>
-                       <Participation user={user} />
-               </Col>
-       </Row>
-</Container>;
-
-Profile.propTypes = {
-       user: PropTypes.shape({
-               nickname: PropTypes.string,
-               participation: PropTypes.arrayOf(PropTypes.shape({
-               })),
-               random_quote: PropTypes.shape({
-                       comment: PropTypes.string,
-               }),
-               round_first_count: PropTypes.number,
-               round_second_count: PropTypes.number,
-               round_third_count: PropTypes.number,
-               stream_link: PropTypes.string,
-               tournament_first_count: PropTypes.number,
-               tournament_second_count: PropTypes.number,
-               tournament_third_count: PropTypes.number,
-               username: PropTypes.string,
-       }),
-};
-
-export default withTranslation()(Profile);
diff --git a/resources/js/components/users/Profile.jsx b/resources/js/components/users/Profile.jsx
new file mode 100644 (file)
index 0000000..b4c4060
--- /dev/null
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Col, Container, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Box from './Box';
+import Records from './Records';
+import EditNicknameButton from './EditNicknameButton';
+import EditStreamLinkButton from './EditStreamLinkButton';
+import Participation from './Participation';
+import Icon from '../common/Icon';
+import i18n from '../../i18n';
+
+const Profile = ({ user }) => <Container>
+       <h1>
+               {user.nickname || user.username}
+               {' '}
+               <EditNicknameButton user={user} />
+       </h1>
+       {user.random_quote && user.random_quote.comment ?
+               <Alert className="quote-alert" variant="dark">
+                       <blockquote className="blockquote mb-0">
+                               {user.random_quote.comment}
+                       </blockquote>
+               </Alert>
+       : null}
+       <Row>
+               <Col md={6} className="mb-5">
+                       <h2>{i18n.t('users.discordTag')}</h2>
+                       <Box discriminator user={user} />
+               </Col>
+               <Col md={6} className="mb-5">
+                       <h2>{i18n.t('users.streamLink')}</h2>
+                       <p>
+                               {user.stream_link ?
+                                       <Button
+                                               href={user.stream_link}
+                                               target="_blank"
+                                               variant="outline-twitch"
+                                       >
+                                               <Icon.STREAM />
+                                               {' '}
+                                               {user.stream_link}
+                                       </Button>
+                               :
+                                       i18n.t('users.noStream')
+                               }
+                               {' '}
+                               <EditStreamLinkButton user={user} />
+                       </p>
+               </Col>
+               <Col md={6} className="mb-5">
+                       <h2>{i18n.t('users.tournamentRecords')}</h2>
+                       <Records
+                               first={user.tournament_first_count}
+                               second={user.tournament_second_count}
+                               third={user.tournament_third_count}
+                       />
+               </Col>
+               <Col md={6} className="mb-5">
+                       <h2>{i18n.t('users.roundRecords')}</h2>
+                       <Records
+                               first={user.round_first_count}
+                               second={user.round_second_count}
+                               third={user.round_third_count}
+                       />
+               </Col>
+               <Col md={12} className="mb-5">
+                       <h2>{i18n.t('users.tournaments')}</h2>
+                       <Participation user={user} />
+               </Col>
+       </Row>
+</Container>;
+
+Profile.propTypes = {
+       user: PropTypes.shape({
+               nickname: PropTypes.string,
+               participation: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               random_quote: PropTypes.shape({
+                       comment: PropTypes.string,
+               }),
+               round_first_count: PropTypes.number,
+               round_second_count: PropTypes.number,
+               round_third_count: PropTypes.number,
+               stream_link: PropTypes.string,
+               tournament_first_count: PropTypes.number,
+               tournament_second_count: PropTypes.number,
+               tournament_third_count: PropTypes.number,
+               username: PropTypes.string,
+       }),
+};
+
+export default withTranslation()(Profile);
diff --git a/resources/js/components/users/Records.js b/resources/js/components/users/Records.js
deleted file mode 100644 (file)
index 5b110fe..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Col, Row } from 'react-bootstrap';
-
-import Icon from '../common/Icon';
-
-const Records = ({
-       first,
-       second,
-       third,
-}) => <Row>
-       <Col>
-               <div className="record-box">
-                       <span className="icon">
-                               <Icon.FIRST_PLACE className="text-gold" />
-                       </span>
-                       <span className="count">
-                               {first}
-                       </span>
-               </div>
-       </Col>
-       <Col>
-               <div className="record-box">
-                       <span className="icon">
-                               <Icon.SECOND_PLACE className="text-silver" />
-                       </span>
-                       <span className="count">
-                               {second}
-                       </span>
-               </div>
-       </Col>
-       <Col>
-               <div className="record-box">
-                       <span className="icon">
-                               <Icon.THIRD_PLACE className="text-bronze" />
-                       </span>
-                       <span className="count">
-                               {third}
-                       </span>
-               </div>
-       </Col>
-</Row>;
-
-Records.propTypes = {
-       first: PropTypes.number,
-       second: PropTypes.number,
-       third: PropTypes.number,
-};
-
-export default Records;
diff --git a/resources/js/components/users/Records.jsx b/resources/js/components/users/Records.jsx
new file mode 100644 (file)
index 0000000..5b110fe
--- /dev/null
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Row } from 'react-bootstrap';
+
+import Icon from '../common/Icon';
+
+const Records = ({
+       first,
+       second,
+       third,
+}) => <Row>
+       <Col>
+               <div className="record-box">
+                       <span className="icon">
+                               <Icon.FIRST_PLACE className="text-gold" />
+                       </span>
+                       <span className="count">
+                               {first}
+                       </span>
+               </div>
+       </Col>
+       <Col>
+               <div className="record-box">
+                       <span className="icon">
+                               <Icon.SECOND_PLACE className="text-silver" />
+                       </span>
+                       <span className="count">
+                               {second}
+                       </span>
+               </div>
+       </Col>
+       <Col>
+               <div className="record-box">
+                       <span className="icon">
+                               <Icon.THIRD_PLACE className="text-bronze" />
+                       </span>
+                       <span className="count">
+                               {third}
+                       </span>
+               </div>
+       </Col>
+</Row>;
+
+Records.propTypes = {
+       first: PropTypes.number,
+       second: PropTypes.number,
+       third: PropTypes.number,
+};
+
+export default Records;
diff --git a/resources/js/components/zootr/MixedPoolsTracker.js b/resources/js/components/zootr/MixedPoolsTracker.js
deleted file mode 100644 (file)
index ee7f954..0000000
+++ /dev/null
@@ -1,3288 +0,0 @@
-import FuzzySearch from 'fuzzy-search';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import Icon from '../common/Icon';
-
-const AREAS = [
-       {
-               id: 'kf',
-               bgColor: '#6aa84f',
-               fgColor: '#000000',
-               name: 'Kokiri Forest',
-               short: 'KF',
-               map: {
-                       pos: { x: 210, y: 240 },
-                       size: { x: 150, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/kf.png',
-                               off: { x: 0, y: 0 },
-                               scale: 1.3,
-                       },
-                       labelPos: { x: 90, y: 70 },
-               },
-               entrances: [
-                       {
-                               id: 'bro',
-                               bgColor: '#b6d7a8',
-                               fgColor: '#000000',
-                               name: 'Know-It-All Brothers\' House',
-                               short: 'Bros',
-                               type: 'Interior',
-                               pos: { x: 13, y: 57 },
-                       },
-                       {
-                               id: 'link',
-                               bgColor: '#b6d7a8',
-                               fgColor: '#000000',
-                               name: 'Link\'s House',
-                               short: 'Links',
-                               type: 'SpecialInterior',
-                               pos: { x: 36, y: 74 },
-                       },
-                       {
-                               id: 'sariah',
-                               bgColor: '#b6d7a8',
-                               fgColor: '#000000',
-                               name: 'Sariah\'s House',
-                               short: 'Sariahs',
-                               type: 'Interior',
-                               pos: { x: 47, y: 66 },
-                       },
-                       {
-                               id: 'twin',
-                               bgColor: '#b6d7a8',
-                               fgColor: '#000000',
-                               name: 'House of Twins',
-                               short: 'Twins',
-                               type: 'Interior',
-                               pos: { x: 58, y: 64 },
-                       },
-                       {
-                               id: 'shop',
-                               bgColor: '#b6d7a8',
-                               fgColor: '#000000',
-                               name: 'Kokiri Shop',
-                               short: 'Shop',
-                               type: 'Interior',
-                               pos: { x: 53, y: 42 },
-                       },
-                       {
-                               id: 'mido',
-                               bgColor: '#b6d7a8',
-                               fgColor: '#000000',
-                               name: 'Great Mido\'s House',
-                               short: 'Mido',
-                               type: 'Interior',
-                               pos: { x: 28, y: 38 },
-                       },
-                       {
-                               id: 'deku',
-                               bgColor: '#ead1dc',
-                               fgColor: '#000000',
-                               name: 'Deku Tree',
-                               short: 'Deku',
-                               spacer: true,
-                               type: 'Dungeon',
-                               pos: { x: 114, y: 26 },
-                       },
-                       {
-                               id: 'storms',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Storms Grotto',
-                               short: 'Storms Grotto',
-                               type: 'Grotto',
-                               pos: { x: 25, y: 25 },
-                       },
-                       {
-                               id: 'hf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Hyrule Field',
-                               short: 'HF',
-                               type: 'Overworld',
-                               pos: { x: 4, y: 45 },
-                       },
-                       {
-                               id: 'lw',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Lost Woods',
-                               short: 'LW',
-                               type: 'Overworld',
-                               pos: { x: 31, y: 21 },
-                       },
-               ],
-       },
-       {
-               id: 'lw',
-               bgColor: '#38761d',
-               fgColor: '#000000',
-               name: 'Lost Woods',
-               short: 'LW',
-               map: {
-                       pos: { x: 370, y: 220 },
-                       size: { x: 75, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/lw.png',
-                               off: { x: 0, y: 0 },
-                               scale: 1.3,
-                       },
-                       labelPos: { x: 40, y: 85 },
-               },
-               entrances: [
-                       {
-                               id: 'gcg',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Goron City Grotto',
-                               short: 'GC Grotto',
-                               type: 'Grotto',
-                               pos: { x: 45, y: 40 },
-                       },
-                       {
-                               id: 'tg',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Theater Grotto',
-                               short: 'Theater Grotto',
-                               type: 'Grotto',
-                               pos: { x: 32, y: 25 },
-                       },
-                       {
-                               id: 'sfmg',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'SFM Grotto',
-                               short: 'SFM Grotto',
-                               type: 'Grotto',
-                               pos: { x: 40, y: 12 },
-                       },
-                       {
-                               id: 'kf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Kokiri Forest',
-                               short: 'KF',
-                               spacer: true,
-                               type: 'Overworld',
-                               pos: { x: 31, y: 60 },
-                       },
-                       {
-                               id: 'gc',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Goron City',
-                               short: 'GC',
-                               type: 'Overworld',
-                               pos: { x: 44, y: 33 },
-                       },
-                       {
-                               id: 'zr',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Zora\'s River',
-                               short: 'ZR',
-                               type: 'Overworld',
-                               pos: { x: 68, y: 38 },
-                       },
-                       {
-                               id: 'sfm',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Sacred Forest Maedows',
-                               short: 'SFM',
-                               type: 'Overworld',
-                               pos: { x: 45, y: 6 },
-                       },
-               ],
-       },
-       {
-               id: 'sfm',
-               bgColor: '#274e13',
-               fgColor: '#000000',
-               name: 'Sacred Forest Maedows',
-               short: 'SFM',
-               map: {
-                       pos: { x: 460, y: 220 },
-                       size: { x: 35, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/sfm.png',
-                               off: { x: -20, y: 0 },
-                               scale: 4.3,
-                       },
-                       labelPos: { x: 15, y: 110 },
-               },
-               entrances: [
-                       {
-                               id: 'forest',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Forest Temple',
-                               short: 'Forest Temple',
-                               type: 'Dungeon',
-                               pos: { x: 18, y: 5 },
-                       },
-                       {
-                               id: 'wolf',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Wolf Grotto',
-                               short: 'Wolf Grotto',
-                               type: 'Grotto',
-                               pos: { x: 14, y: 87 },
-                       },
-                       {
-                               id: 'fairy',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Fairy Grotto',
-                               short: 'Fairy Grotto',
-                               type: 'Grotto',
-                               pos: { x: 18, y: 60 },
-                       },
-                       {
-                               id: 'storms',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Storms Grotto',
-                               short: 'Storms Grotto',
-                               type: 'Grotto',
-                               pos: { x: 25, y: 18 },
-                       },
-                       {
-                               id: 'lw',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Lost Woods',
-                               short: 'LW',
-                               type: 'Overworld',
-                               pos: { x: 14, y: 95 },
-                       },
-               ],
-       },
-       {
-               id: 'gy',
-               bgColor: '#a64d79',
-               fgColor: '#000000',
-               name: 'Graveyard',
-               short: 'Grave',
-               map: {
-                       pos: { x: 310, y: 170 },
-                       size: { x: 100, y: 35, },
-                       bg: {
-                               src: '/media/oot/minimap/gy.png',
-                               off: { x: 0, y: -5 },
-                               scale: 1.5,
-                       },
-                       labelPos: { x: 65, y: 35 },
-               },
-               entrances: [
-                       {
-                               id: 'dh',
-                               bgColor: '#c27ba0',
-                               fgColor: '#000000',
-                               name: 'Dampe Hut',
-                               short: 'Dampe Hut',
-                               type: 'Interior',
-                               pos: { x: 22, y: 26 },
-                       },
-                       {
-                               id: 'shadow',
-                               bgColor: '#c27ba0',
-                               fgColor: '#000000',
-                               name: 'Shadow Temple',
-                               short: 'Shadow',
-                               type: 'Dungeon',
-                               pos: { x: 90, y: 16 },
-                       },
-                       {
-                               id: 'shield',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Shield Grave',
-                               short: 'Shield Grave',
-                               type: 'Grave',
-                               pos: { x: 30, y: 19 },
-                       },
-                       {
-                               id: 'race',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Dampe Race',
-                               short: 'Dampe Race',
-                               type: 'Grave',
-                               pos: { x: 25, y: 7 },
-                       },
-                       {
-                               id: 'sun',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Sun Song Grave',
-                               short: 'Sun Song Gr',
-                               type: 'Grave',
-                               pos: { x: 41, y: 21 },
-                       },
-                       {
-                               id: 'family',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Family Tomb',
-                               short: 'Family Tomb',
-                               type: 'Grave',
-                               pos: { x: 52, y: 15 },
-                       },
-                       {
-                               id: 'kak',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Kakariko',
-                               short: 'Kak',
-                               type: 'Overworld',
-                               pos: { x: 5, y: 20 },
-                       },
-               ],
-       },
-       {
-               id: 'kak',
-               bgColor: '#ff9900',
-               fgColor: '#000000',
-               name: 'Kakariko Village',
-               short: 'Kak',
-               map: {
-                       pos: { x: 190, y: 140 },
-                       size: { x: 125, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/kak.png',
-                               off: { x: -7, y: 0 },
-                               scale: 1.7,
-                       },
-                       labelPos: { x: 20, y: 95 },
-               },
-               entrances: [
-                       {
-                               id: 'talon',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Carpenter Boss House',
-                               short: 'Talons',
-                               type: 'Interior',
-                               pos: { x: 66, y: 51 },
-                       },
-                       {
-                               id: 'skull',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Skulltula House',
-                               short: 'Skulltula',
-                               type: 'Interior',
-                               pos: { x: 58, y: 73 },
-                       },
-                       {
-                               id: 'impaf',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Impa Front',
-                               short: 'Impa Front',
-                               type: 'Interior',
-                               pos: { x: 58, y: 92 },
-                       },
-                       {
-                               id: 'impab',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Impa Back',
-                               short: 'Impa Back',
-                               type: 'Interior',
-                               pos: { x: 71, y: 91 },
-                       },
-                       {
-                               id: 'shield',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Shield Shop (Bazaar)',
-                               short: 'Shield Shop',
-                               type: 'Interior',
-                               pos: { x: 60, y: 28 },
-                       },
-                       {
-                               id: 'potion',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Potion Shop',
-                               short: 'Potion Shop',
-                               type: 'SpecialInterior',
-                               pos: { x: 70, y: 28 },
-                       },
-                       {
-                               id: 'back',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Shop Back',
-                               short: 'Shop Back',
-                               type: 'SpecialInterior',
-                               pos: { x: 80, y: 28 },
-                       },
-                       {
-                               id: 'witch',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Witch',
-                               short: 'Witch',
-                               type: 'Interior',
-                               pos: { x: 84, y: 46 },
-                       },
-                       {
-                               id: 'arch',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Archery',
-                               short: 'Archery',
-                               type: 'Interior',
-                               pos: { x: 72, y: 67 },
-                       },
-                       {
-                               id: 'mill',
-                               bgColor: '#f9cb9c',
-                               fgColor: '#000000',
-                               name: 'Windmill',
-                               short: 'Windmill',
-                               type: 'SpecialInterior',
-                               pos: { x: 96, y: 59 },
-                       },
-                       {
-                               id: 'botw',
-                               bgColor: '#a64d79',
-                               fgColor: '#000000',
-                               name: 'Bottom of the Well',
-                               short: 'Bottom Well',
-                               spacer: true,
-                               type: 'Dungeon',
-                               pos: { x: 84, y: 59 },
-                       },
-                       {
-                               id: 'open',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Open Grotto',
-                               short: 'Open Grotto',
-                               type: 'Grotto',
-                               pos: { x: 86, y: 37 },
-                       },
-                       {
-                               id: 'redead',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Redead Grotto',
-                               short: 'Redead Grotto',
-                               type: 'Grotto',
-                               pos: { x: 58, y: 57 },
-                       },
-                       {
-                               id: 'hf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Hyrule Field',
-                               short: 'HF',
-                               type: 'Overworld',
-                               pos: { x: 10, y: 74 },
-                       },
-                       {
-                               id: 'dmt',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Death Mountain Trail',
-                               short: 'DMT',
-                               type: 'Overworld',
-                               pos: { x: 67, y: 8 },
-                       },
-                       {
-                               id: 'gy',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Graveyard',
-                               short: 'Grave',
-                               type: 'Overworld',
-                               pos: { x: 112, y: 83 },
-                       },
-               ],
-       },
-       {
-               id: 'm1',
-               bgColor: '#9900ff',
-               fgColor: '#000000',
-               name: 'Market 1',
-               short: 'M1',
-               map: {
-                       pos: { x: 100, y: 80 },
-                       size: { x: 100, y: 60, },
-                       bg: {
-                               src: '/media/oot/minimap/m1.png',
-                               off: { x: 0, y: -6 },
-                               scale: 1.0,
-                       },
-                       labelPos: { x: 38, y: 30 },
-               },
-               entrances: [
-                       {
-                               id: 'shield',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Shield Shop',
-                               short: 'Shield Shop',
-                               type: 'Interior',
-                               pos: { x: 81, y: 36 },
-                       },
-                       {
-                               id: 'potion',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Potion Shop',
-                               short: 'Potion Shop',
-                               type: 'Interior',
-                               pos: { x: 81, y: 27 },
-                       },
-                       {
-                               id: 'mask',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Mask Shop',
-                               short: 'Mask Shop',
-                               type: 'Interior',
-                               pos: { x: 75, y: 14 },
-                       },
-                       {
-                               id: 'sling',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Slingshot Game',
-                               short: 'Sling Game',
-                               type: 'Interior',
-                               pos: { x: 60, y: 14 },
-                       },
-                       {
-                               id: 'chuu',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Bombchu Bowling',
-                               short: 'Bombchu',
-                               type: 'Interior',
-                               pos: { x: 54, y: 30 },
-                       },
-                       {
-                               id: 'tcg',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Treasure Chest Game',
-                               short: 'TCG',
-                               type: 'Interior',
-                               pos: { x: 52, y: 48 },
-                       },
-                       {
-                               id: 'alleyl',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Backalley Left',
-                               short: 'Alley L',
-                               type: 'Overworld',
-                               pos: { x: 26, y: 48 },
-                       },
-                       {
-                               id: 'alleyr',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Backalley Right',
-                               short: 'Alley R',
-                               type: 'Overworld',
-                               pos: { x: 18, y: 10 },
-                       },
-                       {
-                               id: 'tot',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Temple of Time',
-                               short: 'ToT',
-                               type: 'Overworld',
-                               pos: { x: 92, y: 15 },
-                       },
-                       {
-                               id: 'hc',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Hyrule Castle',
-                               short: 'HC',
-                               type: 'Overworld',
-                               pos: { x: 67, y: 7 },
-                       },
-                       {
-                               id: 'm2',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Market 2',
-                               short: 'M2',
-                               type: 'Overworld',
-                               pos: { x: 66, y: 50 },
-                       },
-               ],
-       },
-       {
-               id: 'm2',
-               bgColor: '#9900ff',
-               fgColor: '#000000',
-               name: 'Market 2',
-               short: 'M2',
-               map: {
-                       pos: { x: 205, y: 85 },
-                       size: { x: 18, y: 50, },
-                       bg: {
-                               src: '/media/oot/minimap/m2.png',
-                               off: { x: -3, y: -3 },
-                               scale: 3.6,
-                       },
-                       labelPos: { x: -2, y: 25 },
-               },
-               entrances: [
-                       {
-                               id: 'bp',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Big Poe',
-                               short: 'Big Poe',
-                               type: 'Interior',
-                               pos: { x: 15.5, y: 32 },
-                       },
-                       {
-                               id: 'hf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Hyrule Field',
-                               short: 'HF',
-                               type: 'Overworld',
-                               pos: { x: 8.5, y: 47.5 },
-                       },
-                       {
-                               id: 'm1',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Market 1',
-                               short: 'M1',
-                               type: 'Overworld',
-                               pos: { x: 8.5, y: 3 },
-                       },
-               ],
-       },
-       {
-               id: 'hc',
-               bgColor: '#bf9000',
-               fgColor: '#000000',
-               name: 'Hyrule Castle',
-               short: 'HC',
-               map: {
-                       pos: { x: 100, y: 0 },
-                       size: { x: 120, y: 70, },
-                       bg: {
-                               src: '/media/oot/minimap/hc-gc.png',
-                               off: { x: 0, y: 0 },
-                               scale: 1.2,
-                       },
-                       labelPos: { x: 25, y: 65 },
-               },
-               entrances: [
-                       {
-                               id: 'hcfairy',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Hyrule Castle Fairy',
-                               short: 'HC Fairy',
-                               type: 'Interior',
-                               pos: { x: 61, y: 23 },
-                       },
-                       {
-                               id: 'gfairy',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Ganon\'s Castle Fairy',
-                               short: 'Ganon Fairy',
-                               type: 'Interior',
-                               pos: { x: 116, y: 51 },
-                       },
-                       {
-                               id: 'igc',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Inside Ganon\'s Castle',
-                               short: 'IGC',
-                               type: 'DungeonSpecial',
-                               pos: { x: 83, y: 30 },
-                       },
-                       {
-                               id: 'storms',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Storms Grotto',
-                               short: 'Storms Grotto',
-                               type: 'Grotto',
-                               pos: { x: 30, y: 29 },
-                       },
-                       {
-                               id: 'm1',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Market 1',
-                               short: 'M1',
-                               type: 'Overworld',
-                               pos: { x: 60, y: 66 },
-                       },
-               ],
-       },
-       {
-               id: 'hf',
-               bgColor: '#674ea7',
-               fgColor: '#000000',
-               name: 'Hyrule Field',
-               short: 'HF',
-               map: {
-                       pos: { x: 90, y: 150 },
-                       size: { x: 100, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/hf.png',
-                               off: { x: -16, y: 0 },
-                               scale: 1.8,
-                       },
-                       labelPos: { x: 100, y: 10 },
-               },
-               entrances: [
-                       {
-                               id: 'destiny',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Destiny Grotto',
-                               short: 'Destiny Grotto',
-                               type: 'Grotto',
-                               pos: { x: 33, y: 7 },
-                       },
-                       {
-                               id: 'tektite',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Tektite Grotto',
-                               short: 'Tektite',
-                               type: 'Grotto',
-                               pos: { x: 30, y: 23 },
-                       },
-                       {
-                               id: 'nw',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Northwest Grotto (near Market)',
-                               short: 'NW Grotto',
-                               type: 'Grotto',
-                               pos: { x: 48, y: 13 },
-                       },
-                       {
-                               id: 'nk',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Grotto near Kakariko',
-                               short: 'near Kak Gro',
-                               type: 'Grotto',
-                               pos: { x: 68, y: 8 },
-                       },
-                       {
-                               id: 'se',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Southeast Grotto',
-                               short: 'SE Grotto',
-                               type: 'Grotto',
-                               pos: { x: 55, y: 78 },
-                       },
-                       {
-                               id: 'open',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Open Grotto',
-                               short: 'Open Grotto',
-                               type: 'Grotto',
-                               pos: { x: 35, y: 86 },
-                       },
-                       {
-                               id: 'sg',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'South Grotto',
-                               short: 'South Grotto',
-                               type: 'Grotto',
-                               pos: { x: 28, y: 87 },
-                       },
-                       {
-                               id: 'cow',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Cow Grotto',
-                               short: 'Cow Grotto',
-                               type: 'Grotto',
-                               pos: { x: 13, y: 47 },
-                       },
-                       {
-                               id: 'town',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Town',
-                               short: 'Town',
-                               spacer: true,
-                               type: 'Overworld',
-                               pos: { x: 57, y: 10 },
-                       },
-                       {
-                               id: 'llr',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Lon Lon Ranch',
-                               short: 'LLR',
-                               type: 'Overworld',
-                               pos: { x: 45, y: 44 },
-                       },
-                       {
-                               id: 'kak',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Kakariko',
-                               short: 'Kak',
-                               type: 'Overworld',
-                               pos: { x: 80, y: 10 },
-                       },
-                       {
-                               id: 'zr',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Zora\'s River',
-                               short: 'ZR',
-                               type: 'Overworld',
-                               pos: { x: 94, y: 30 },
-                       },
-                       {
-                               id: 'kf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Kokiri Forest',
-                               short: 'KF',
-                               type: 'Overworld',
-                               pos: { x: 87, y: 57 },
-                       },
-                       {
-                               id: 'lh',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Lake Hylia',
-                               short: 'LH',
-                               type: 'Overworld',
-                               pos: { x: 25, y: 95 },
-                       },
-                       {
-                               id: 'gv',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Gerudo Valley',
-                               short: 'GV',
-                               type: 'Overworld',
-                               pos: { x: 6, y: 51 },
-                       },
-               ],
-       },
-       {
-               id: 'zr',
-               bgColor: '#3c78d8',
-               fgColor: '#000000',
-               name: 'Zora\'s River',
-               short: 'ZR',
-               map: {
-                       pos: { x: 330, y: 100 },
-                       size: { x: 100, y: 60, },
-                       bg: {
-                               src: '/media/oot/minimap/zr.png',
-                               off: { x: 0, y: 0 },
-                               scale: 1.3,
-                       },
-                       labelPos: { x: 60, y: 40 },
-               },
-               entrances: [
-                       {
-                               id: 'storms',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Storms Grotto',
-                               short: 'Storms Grotto',
-                               type: 'Grotto',
-                               pos: { x: 8, y: 28 },
-                       },
-                       {
-                               id: 'open',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Open Grotto',
-                               short: 'Open Grotto',
-                               type: 'Grotto',
-                               pos: { x: 36, y: 32 },
-                       },
-                       {
-                               id: 'boulder',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Boulder Grotto',
-                               short: 'Boulder Grotto',
-                               type: 'Grotto',
-                               pos: { x: 40, y: 24 },
-                       },
-                       {
-                               id: 'hf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Hyrule Field',
-                               short: 'HF',
-                               spacer: true,
-                               type: 'Overworld',
-                               pos: { x: 10, y: 52 },
-                       },
-                       {
-                               id: 'lw',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Lost Woods',
-                               short: 'LW',
-                               type: 'Overworld',
-                               pos: { x: 90.5, y: 18 },
-                       },
-                       {
-                               id: 'zd',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Zora\'s Domain',
-                               short: 'ZD',
-                               type: 'Overworld',
-                               pos: { x: 96, y: 9 },
-                       },
-               ],
-       },
-       {
-               id: 'zd',
-               bgColor: '#3c78d8',
-               fgColor: '#000000',
-               name: 'Zora\'s Domain',
-               short: 'ZD',
-               map: {
-                       pos: { x: 410, y: 100 },
-                       size: { x: 80, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/zd.png',
-                               off: { x: -15, y: -5 },
-                               scale: 2.3,
-                       },
-                       labelPos: { x: 18, y: 40 },
-               },
-               entrances: [
-                       {
-                               id: 'shop',
-                               bgColor: '#aac2f1',
-                               fgColor: '#000000',
-                               name: 'Shop',
-                               short: 'Shop',
-                               type: 'Interior',
-                               pos: { x: 55, y: 85 },
-                       },
-                       {
-                               id: 'storms',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Storms Grotto',
-                               short: 'Storms Grotto',
-                               type: 'Grotto',
-                               pos: { x: 16, y: 64 },
-                       },
-                       {
-                               id: 'zr',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Zora\'s River',
-                               short: 'ZR',
-                               type: 'Overworld',
-                               pos: { x: 6, y: 73 },
-                       },
-                       {
-                               id: 'lh',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Lake Hylia',
-                               short: 'LH',
-                               type: 'Overworld',
-                               pos: { x: 35, y: 72 },
-                       },
-                       {
-                               id: 'zf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Zora\'s Fountain',
-                               short: 'ZF',
-                               type: 'Overworld',
-                               pos: { x: 58, y: 15 },
-                       },
-               ],
-       },
-       {
-               id: 'zf',
-               bgColor: '#3c78d8',
-               fgColor: '#000000',
-               name: 'Zora\'s Fountain',
-               short: 'ZF',
-               map: {
-                       pos: { x: 410, y: 0 },
-                       size: { x: 90, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/zf.png',
-                               off: { x: -5, y: 0 },
-                               scale: 2.1,
-                       },
-                       labelPos: { x: 60, y: 35 },
-               },
-               entrances: [
-                       {
-                               id: 'wall',
-                               bgColor: '#aac2f1',
-                               fgColor: '#000000',
-                               name: 'Fairy Wall',
-                               short: 'Fairy Wall',
-                               type: 'Interior',
-                               pos: { x: 61, y: 90 },
-                       },
-                       {
-                               id: 'jabu',
-                               bgColor: '#ead1dc',
-                               fgColor: '#000000',
-                               name: 'Jabu Jabu\'s Belly',
-                               short: 'Jabu',
-                               type: 'Dungeon',
-                               pos: { x: 34, y: 38 },
-                       },
-                       {
-                               id: 'ice',
-                               bgColor: '#a64d79',
-                               fgColor: '#000000',
-                               name: 'Ice Cavern',
-                               short: 'Ice Cavern',
-                               type: 'Dungeon',
-                               pos: { x: 52, y: 10 },
-                       },
-                       {
-                               id: 'zd',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Zora\'s Domain',
-                               short: 'ZD',
-                               type: 'Overworld',
-                               pos: { x: 12, y: 55 },
-                       },
-               ],
-       },
-       {
-               id: 'lh',
-               bgColor: '#4a86e8',
-               fgColor: '#000000',
-               name: 'Lake Hylia',
-               short: 'LH',
-               map: {
-                       pos: { x: 0, y: 265 },
-                       size: { x: 90, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/lh.png',
-                               off: { x: -15, y: -5 },
-                               scale: 1.9,
-                       },
-                       labelPos: { x: 70, y: 65 },
-               },
-               entrances: [
-                       {
-                               id: 'dive',
-                               bgColor: '#cfe2f3',
-                               fgColor: '#000000',
-                               name: 'Lab Diving',
-                               short: 'Lab Dive',
-                               type: 'Interior',
-                               pos: { x: 30, y: 43 },
-                       },
-                       {
-                               id: 'fishing',
-                               bgColor: '#cfe2f3',
-                               fgColor: '#000000',
-                               name: 'Fishing Game',
-                               short: 'Fishing',
-                               type: 'Interior',
-                               pos: { x: 68, y: 43 },
-                       },
-                       {
-                               id: 'water',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Water Temple',
-                               short: 'Water Temple',
-                               type: 'Dungeon',
-                               pos: { x: 45, y: 65 },
-                       },
-                       {
-                               id: 'owl',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Owl Grotto',
-                               short: 'Owl Grotto',
-                               type: 'Grotto',
-                               pos: { x: 24, y: 65 },
-                       },
-                       {
-                               id: 'hf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Hyrule Field',
-                               short: 'HF',
-                               type: 'Overworld',
-                               pos: { x: 35, y: 5 },
-                       },
-                       {
-                               id: 'zd',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Zora\'s Domain',
-                               short: 'ZD',
-                               type: 'Overworld',
-                               pos: { x: 45, y: 40 },
-                       },
-               ],
-       },
-       {
-               id: 'llr',
-               bgColor: '#f1c232',
-               fgColor: '#000000',
-               name: 'Lon Lon Ranch',
-               short: 'LLR',
-               map: {
-                       pos: { x: 100, y: 260 },
-                       size: { x: 80, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/llr.png',
-                               off: { x: -22, y: -6 },
-                               scale: 2.6,
-                       },
-                       labelPos: { x: 40, y: 50 },
-               },
-               entrances: [
-                       {
-                               id: 'chicken',
-                               bgColor: '#ffd966',
-                               fgColor: '#000000',
-                               name: 'Chicken Game',
-                               short: 'Chicken',
-                               type: 'Interior',
-                               pos: { x: 54, y: 13 },
-                       },
-                       {
-                               id: 'stable',
-                               bgColor: '#ffd966',
-                               fgColor: '#000000',
-                               name: 'Stable',
-                               short: 'Stable',
-                               type: 'Interior',
-                               pos: { x: 50, y: 19 },
-                       },
-                       {
-                               id: 'tower',
-                               bgColor: '#ffd966',
-                               fgColor: '#000000',
-                               name: 'Tower',
-                               short: 'Tower',
-                               type: 'Interior',
-                               pos: { x: 17, y: 82 },
-                       },
-                       {
-                               id: 'grotto',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Grotto',
-                               short: 'Grotto',
-                               type: 'Grotto',
-                               pos: { x: 60, y: 85 },
-                       },
-                       {
-                               id: 'hf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Hyrule Field',
-                               short: 'HF',
-                               type: 'Overworld',
-                               pos: { x: 60, y: 5 },
-                       },
-               ],
-       },
-       {
-               id: 'gv',
-               bgColor: '#b45f06',
-               fgColor: '#000000',
-               name: 'Gerudo Valley',
-               short: 'GV',
-               map: {
-                       pos: { x: 0, y: 190 },
-                       size: { x: 100, y: 75, },
-                       bg: {
-                               src: '/media/oot/minimap/gv.png',
-                               off: { x: -17, y: -5 },
-                               scale: 1.9,
-                       },
-                       labelPos: { x: 20, y: 55 },
-               },
-               entrances: [
-                       {
-                               id: 'tent',
-                               bgColor: '#b45f06',
-                               fgColor: '#000000',
-                               name: 'Tent',
-                               short: 'Tent',
-                               type: 'Interior',
-                               pos: { x: 44, y: 33 },
-                       },
-                       {
-                               id: 'str2',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Strength 2 Grotto',
-                               short: 'Str2 Grotto',
-                               type: 'Grotto',
-                               pos: { x: 60, y: 60 },
-                       },
-                       {
-                               id: 'storms',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Storms Grotto',
-                               short: 'Storms Grotto',
-                               type: 'Grotto',
-                               pos: { x: 39, y: 27 },
-                       },
-                       {
-                               id: 'hf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Hyrule Field',
-                               short: 'HF',
-                               type: 'Overworld',
-                               pos: { x: 93, y: 45 },
-                       },
-                       {
-                               id: 'gf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Gerudo Fortress',
-                               short: 'GF',
-                               type: 'Overworld',
-                               pos: { x: 6, y: 22 },
-                       },
-                       {
-                               id: 'wf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Waterfall',
-                               short: 'Waterfall',
-                               oneway: true,
-                               type: 'OverworldOneWay',
-                               pos: { x: 58, y: 69 },
-                       },
-               ],
-       },
-       {
-               id: 'gf',
-               bgColor: '#b45f06',
-               fgColor: '#000000',
-               name: 'Gerudo Fortress',
-               short: 'GF',
-               map: {
-                       pos: { x: 0, y: 90 },
-                       size: { x: 90, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/gf.png',
-                               off: { x: -5, y: -35 },
-                               scale: 2.5,
-                       },
-                       labelPos: { x: 15, y: 50 },
-               },
-               entrances: [
-                       {
-                               id: 'gtg',
-                               bgColor: '#a64d79',
-                               fgColor: '#000000',
-                               name: 'Gerudo Training Grounds',
-                               short: 'GTG',
-                               type: 'Dungeon',
-                               pos: { x: 48, y: 60 },
-                       },
-                       {
-                               id: 'storms',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Storms Grotto',
-                               short: 'Storms Grotto',
-                               type: 'Grotto',
-                               pos: { x: 52, y: 47 },
-                       },
-                       {
-                               id: 'gv',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Gerudo Valley',
-                               short: 'GV',
-                               type: 'Overworld',
-                               pos: { x: 43, y: 93 },
-                       },
-                       {
-                               id: 'hw',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Haunted Wasteland',
-                               short: 'Waste',
-                               type: 'Overworld',
-                               pos: { x: 7, y: 13 },
-                       },
-               ],
-       },
-       {
-               id: 'dcol',
-               bgColor: '#f1c232',
-               fgColor: '#000000',
-               name: 'Desert Colossus',
-               short: 'DCol',
-               map: {
-                       pos: { x: 0, y: 0 },
-                       size: { x: 100, y: 90, },
-                       bg: {
-                               src: '/media/oot/minimap/dcol.png',
-                               off: { x: 0, y: -2 },
-                               scale: 2.0,
-                       },
-                       labelPos: { x: 50, y: 50 },
-               },
-               entrances: [
-                       {
-                               id: 'spirit',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Spirit Temple',
-                               short: 'Spirit',
-                               type: 'Dungeon',
-                               pos: { x: 10, y: 43.5 },
-                       },
-                       {
-                               id: 'fairy',
-                               bgColor: '#ffd966',
-                               fgColor: '#000000',
-                               name: 'Fairy',
-                               short: 'Fairy',
-                               type: 'Interior',
-                               pos: { x: 62, y: 21 },
-                       },
-                       {
-                               id: 'str2',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Strength 2 Grotto',
-                               short: 'Str2 Grotto',
-                               type: 'Grotto',
-                               pos: { x: 32, y: 28 },
-                       },
-                       {
-                               id: 'hw',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Haunted Wasteland',
-                               short: 'Waste',
-                               type: 'Overworld',
-                               pos: { x: 85, y: 40 },
-                       },
-               ],
-       },
-       {
-               id: 'hw',
-               bgColor: '#f1c232',
-               fgColor: '#000000',
-               name: 'Haunted Wasteland',
-               short: 'Waste',
-               entrances: [
-                       {
-                               id: 'gf',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Gerudo Fortress',
-                               short: 'GF',
-                               type: 'Overworld',
-                               throughway: 'hw.dcol',
-                       },
-                       {
-                               id: 'dcol',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Desert Colossus',
-                               short: 'DCol',
-                               type: 'Overworld',
-                               throughway: 'hw.gf',
-                       },
-               ],
-       },
-       {
-               id: 'dmc',
-               bgColor: '#ff0000',
-               fgColor: '#000000',
-               name: 'Death Mountain Crater',
-               short: 'DMC',
-               map: {
-                       pos: { x: 320, y: 0 },
-                       size: { x: 90, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/dmc.png',
-                               off: { x: -15, y: -3 },
-                               scale: 1.9,
-                       },
-                       labelPos: { x: 52, y: 62 },
-               },
-               entrances: [
-                       {
-                               id: 'fairy',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Fairy',
-                               short: 'Fairy',
-                               type: 'Interior',
-                               pos: { x: 17, y: 68 },
-                       },
-                       {
-                               id: 'fire',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Fire Temple',
-                               short: 'Fire',
-                               type: 'Dungeon',
-                               pos: { x: 48, y: 6 },
-                       },
-                       {
-                               id: 'boulder',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Boulder Grotto',
-                               short: 'Boulder Gro',
-                               type: 'Grotto',
-                               pos: { x: 49, y: 86 },
-                       },
-                       {
-                               id: 'hammer',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Hammer Grotto',
-                               short: 'Hammer Gro',
-                               type: 'Grotto',
-                               pos: { x: 12, y: 35 },
-                       },
-                       {
-                               id: 'gc',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Goron City',
-                               short: 'GC',
-                               type: 'Overworld',
-                               pos: { x: 7, y: 46 },
-                       },
-                       {
-                               id: 'dmt',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Death Mountain Trail',
-                               short: 'DMT',
-                               type: 'Overworld',
-                               pos: { x: 25, y: 90 },
-                       },
-               ],
-       },
-       {
-               id: 'dmt',
-               bgColor: '#ff0000',
-               fgColor: '#000000',
-               name: 'Death Mountain Trail',
-               short: 'DMT',
-               map: {
-                       pos: { x: 280, y: 70 },
-                       size: { x: 40, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/dmt.png',
-                               off: { x: -12, y: 0 },
-                               scale: 3.8,
-                       },
-                       labelPos: { x: -10, y: 50 },
-               },
-               entrances: [
-                       {
-                               id: 'dc',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Dodongo\'s Cavern',
-                               short: 'DC',
-                               type: 'Dungeon',
-                               pos: { x: 8, y: 53 },
-                       },
-                       {
-                               id: 'fairy',
-                               bgColor: '#ea9999',
-                               fgColor: '#000000',
-                               name: 'Fairy',
-                               short: 'Fairy',
-                               type: 'Interior',
-                               pos: { x: 22, y: 10 },
-                       },
-                       {
-                               id: 'storms',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Storms Grotto',
-                               short: 'Storms Grotto',
-                               type: 'Grotto',
-                               pos: { x: 23, y: 48 },
-                       },
-                       {
-                               id: 'cow',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Cow Grotto',
-                               short: 'Cow Grotto',
-                               type: 'Grotto',
-                               pos: { x: 20, y: 57 },
-                       },
-                       {
-                               id: 'kak',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Kakariko',
-                               short: 'Kak',
-                               spacer: true,
-                               type: 'Overworld',
-                               pos: { x: 15, y: 94 },
-                       },
-                       {
-                               id: 'gc',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Goron City',
-                               short: 'GC',
-                               type: 'Overworld',
-                               pos: { x: 24, y: 40 },
-                       },
-                       {
-                               id: 'dmc',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Death Mountain Crater',
-                               short: 'DMC',
-                               type: 'Overworld',
-                               pos: { x: 29, y: 5 },
-                       },
-               ],
-       },
-       {
-               id: 'gc',
-               bgColor: '#ff0000',
-               fgColor: '#000000',
-               name: 'Goron City',
-               short: 'GC',
-               map: {
-                       pos: { x: 220, y: 0 },
-                       size: { x: 90, y: 100, },
-                       bg: {
-                               src: '/media/oot/minimap/gc.png',
-                               off: { x: -2, y: 0 },
-                               scale: 2.0,
-                       },
-                       labelPos: { x: 25, y: 105 },
-               },
-               entrances: [
-                       {
-                               id: 'shop',
-                               bgColor: '#ea9999',
-                               fgColor: '#000000',
-                               name: 'Shop',
-                               short: 'Shop',
-                               type: 'Interior',
-                               pos: { x: 42, y: 50 },
-                       },
-                       {
-                               id: 'times',
-                               bgColor: '#b7b7b7',
-                               fgColor: '#000000',
-                               name: 'Times Grotto',
-                               short: 'Times Grotto',
-                               type: 'Grotto',
-                               pos: { x: 80, y: 17 },
-                       },
-                       {
-                               id: 'dmt',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Death Mountain Trail',
-                               short: 'DMT',
-                               type: 'Overworld',
-                               pos: { x: 50.5, y: 95 },
-                       },
-                       {
-                               id: 'lw',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Lost Woods',
-                               short: 'LW',
-                               type: 'Overworld',
-                               pos: { x: 64, y: 93 },
-                       },
-                       {
-                               id: 'dmc',
-                               bgColor: '#ff6d01',
-                               fgColor: '#000000',
-                               name: 'Death Mountain Crater',
-                               short: 'DMC',
-                               type: 'Overworld',
-                               pos: { x: 51.5, y: 5 },
-                       },
-               ],
-       },
-       {
-               id: 'tot',
-               bgColor: '#ffffff',
-               fgColor: '#000000',
-               name: 'Temple of Time',
-               short: 'ToT',
-               entrances: [
-                       {
-                               id: 'temple',
-                               bgColor: '#b4a7d6',
-                               fgColor: '#000000',
-                               name: 'Temple of Time',
-                               short: 'Temple',
-                               type: 'SpecialInterior',
-                       },
-                       {
-                               id: 'm1',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Market 1',
-                               short: 'M1',
-                               type: 'Overworld',
-                       },
-               ],
-       },
-       {
-               id: 'deku',
-               bgColor: '#ead1dc',
-               fgColor: '#000000',
-               name: 'Deku Tree',
-               short: 'Deku',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#ead1dc',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               type: 'Dungeon',
-                               throughway: 'deku.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#ead1dc',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               type: 'ChildBoss',
-                               throughway: 'deku.main',
-                       },
-               ],
-       },
-       {
-               id: 'gohma',
-               bgColor: '#ead1dc',
-               fgColor: '#000000',
-               name: 'Gohma',
-               short: 'Gohma',
-               type: 'ChildBoss',
-       },
-       {
-               id: 'dc',
-               bgColor: '#ead1dc',
-               fgColor: '#000000',
-               name: 'Dodongo\'s Cavern',
-               short: 'DC',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#ead1dc',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               type: 'Dungeon',
-                               throughway: 'dc.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#ead1dc',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               type: 'ChildBoss',
-                               throughway: 'dc.main',
-                       },
-               ],
-       },
-       {
-               id: 'kd',
-               bgColor: '#ead1dc',
-               fgColor: '#000000',
-               name: 'King Dodongo',
-               short: 'Dodongo',
-               type: 'ChildBoss',
-       },
-       {
-               id: 'jabu',
-               bgColor: '#ead1dc',
-               fgColor: '#000000',
-               name: 'Jabu Jabu\'s Belly',
-               short: 'Jabu',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#ead1dc',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               type: 'Dungeon',
-                               throughway: 'jabu.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#ead1dc',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               type: 'ChildBoss',
-                               throughway: 'jabu.main',
-                       },
-               ],
-       },
-       {
-               id: 'barinade',
-               bgColor: '#ead1dc',
-               fgColor: '#000000',
-               name: 'Barinade',
-               short: 'Barinade',
-               type: 'ChildBoss',
-       },
-       {
-               id: 'forest',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Forest Temple',
-               short: 'Forest',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               spacer: true,
-                               type: 'Dungeon',
-                               throughway: 'forest.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               spacer: true,
-                               type: 'AdultBoss',
-                               throughway: 'forest.main',
-                       },
-               ],
-       },
-       {
-               id: 'pg',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Phantom Ganon',
-               short: 'PG',
-               spacer: true,
-               type: 'AdultBoss',
-       },
-       {
-               id: 'fire',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Fire Temple',
-               short: 'Fire',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               type: 'Dungeon',
-                               throughway: 'fire.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               type: 'AdultBoss',
-                               throughway: 'fire.main',
-                       },
-               ],
-       },
-       {
-               id: 'volvo',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Volvagia',
-               short: 'Volvagia',
-               type: 'AdultBoss',
-       },
-       {
-               id: 'water',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Water Temple',
-               short: 'Water',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               type: 'Dungeon',
-                               throughway: 'water.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               type: 'AdultBoss',
-                               throughway: 'water.main',
-                       },
-               ],
-       },
-       {
-               id: 'morpha',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Morpha',
-               short: 'Morpha',
-               type: 'AdultBoss',
-       },
-       {
-               id: 'shadow',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Shadow Temple',
-               short: 'Shadow',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               type: 'Dungeon',
-                               throughway: 'shadow.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               type: 'AdultBoss',
-                               throughway: 'shadow.main',
-                       },
-               ],
-       },
-       {
-               id: 'bongo',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Bongo Bongo',
-               short: 'Bongo Bongo',
-               type: 'AdultBoss',
-       },
-       {
-               id: 'spirit',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Spirit Temple',
-               short: 'Spirit',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               type: 'Dungeon',
-                               throughway: 'spirit.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#d5a6bd',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               type: 'AdultBoss',
-                               throughway: 'spirit.main',
-                       },
-               ],
-       },
-       {
-               id: 'tr',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Twinrova',
-               short: 'Twinrova',
-               type: 'AdultBoss',
-       },
-       {
-               id: 'igc',
-               bgColor: '#d5a6bd',
-               fgColor: '#000000',
-               name: 'Inside Ganon\'s Castle',
-               short: 'IGC',
-               entrances: [
-                       {
-                               id: 'main',
-                               bgColor: '#a64d79',
-                               fgColor: '#000000',
-                               name: 'Main',
-                               short: 'Main',
-                               spacer: true,
-                               type: 'Dungeon',
-                               throughway: 'igc.end',
-                       },
-                       {
-                               id: 'end',
-                               bgColor: '#a64d79',
-                               fgColor: '#000000',
-                               name: 'End',
-                               short: 'End',
-                               spacer: true,
-                               type: 'SpecialBoss',
-                               throughway: 'igc.main',
-                       },
-               ],
-       },
-       {
-               id: 'ganon',
-               bgColor: '#a64d79',
-               fgColor: '#000000',
-               name: 'Ganon',
-               short: 'Ganon',
-               spacer: true,
-               type: 'SpecialBoss',
-       },
-       {
-               id: 'botw',
-               bgColor: '#a64d79',
-               fgColor: '#000000',
-               name: 'Bottom of the Well',
-               short: 'Bottom Well',
-               type: 'Dungeon',
-       },
-       {
-               id: 'ice',
-               bgColor: '#a64d79',
-               fgColor: '#000000',
-               name: 'Ice Cavern',
-               short: 'Ice Cavern',
-               type: 'Dungeon',
-       },
-       {
-               id: 'gtg',
-               bgColor: '#a64d79',
-               fgColor: '#000000',
-               name: 'Gerudo Training Grounds',
-               short: 'GTG',
-               type: 'Dungeon',
-       },
-       {
-               id: 'songs',
-               bgColor: '#000000',
-               fgColor: '#ffffff',
-               name: 'Warp Songs',
-               short: 'Songs',
-               entrances: [
-                       {
-                               id: 'minuet',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Minuet of Forest',
-                               short: 'Minuet',
-                               oneway: true,
-                               type: 'WarpSong',
-                               icon: '/media/oot/icons/song-minuet.png',
-                       },
-                       {
-                               id: 'bolero',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Bolero of Fire',
-                               short: 'Bolero',
-                               oneway: true,
-                               type: 'WarpSong',
-                               icon: '/media/oot/icons/song-bolero.png',
-                       },
-                       {
-                               id: 'serenade',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Serenade of Water',
-                               short: 'Serenade',
-                               oneway: true,
-                               type: 'WarpSong',
-                               icon: '/media/oot/icons/song-serenade.png',
-                       },
-                       {
-                               id: 'nocturne',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Nocturne of Shadow',
-                               short: 'Nocturne',
-                               oneway: true,
-                               type: 'WarpSong',
-                               icon: '/media/oot/icons/song-nocturne.png',
-                       },
-                       {
-                               id: 'requiem',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Requiem of Spirit',
-                               short: 'Requiem',
-                               oneway: true,
-                               type: 'WarpSong',
-                               icon: '/media/oot/icons/song-requiem.png',
-                       },
-                       {
-                               id: 'prelude',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Prelude of Light',
-                               short: 'Prelude',
-                               oneway: true,
-                               type: 'WarpSong',
-                               icon: '/media/oot/icons/song-prelude.png',
-                       },
-               ],
-       },
-       {
-               id: 'spawns',
-               bgColor: '#000000',
-               fgColor: '#ffffff',
-               name: 'Spawns',
-               short: 'Spawns',
-               entrances: [
-                       {
-                               id: 'child',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Child Spawn',
-                               short: 'Child',
-                               oneway: true,
-                               type: 'Spawn',
-                               icon: '/media/oot/icons/link-child.png',
-                               iconSize: 10,
-                       },
-                       {
-                               id: 'adult',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Adult Spawn',
-                               short: 'Adult',
-                               oneway: true,
-                               type: 'Spawn',
-                               icon: '/media/oot/icons/link-adult.png',
-                       },
-               ],
-       },
-       {
-               id: 'owls',
-               bgColor: '#000000',
-               fgColor: '#ffffff',
-               name: 'Owl Drops',
-               short: 'Owls',
-               entrances: [
-                       {
-                               id: 'lhowl',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Lake Hylia Owl',
-                               short: 'LH Owl',
-                               oneway: true,
-                               type: 'OwlDrop',
-                               icon: '/media/oot/icons/owl-lake.png',
-                       },
-                       {
-                               id: 'dmtowl',
-                               bgColor: '#38761d',
-                               fgColor: '#ffffff',
-                               name: 'Death Mountain Trail Owl',
-                               short: 'Trail Owl',
-                               oneway: true,
-                               type: 'OwlDrop',
-                               icon: '/media/oot/icons/owl-trail.png',
-                       },
-               ],
-       },
-];
-
-const DUNGEONS = [
-       {
-               id: 'd1',
-               bgColor: '#ff00ff',
-               fgColor: '#000000',
-               name: 'Dungeon Entrances',
-               short: 'Dungeon Entrances',
-               type: 'main',
-               entrances: [
-                       'deku.main',
-                       'dc.main',
-                       'jabu.main',
-                       'forest.main',
-                       'fire.main',
-                       'water.main',
-                       'shadow.main',
-                       'spirit.main',
-                       'igc.main',
-                       'botw',
-                       'ice',
-                       'gtg',
-               ],
-       },
-       {
-               id: 'd2',
-               bgColor: '#ff00ff',
-               fgColor: '#000000',
-               name: 'Dungeon Exits',
-               short: 'Dungeon Exits',
-               type: 'end',
-               entrances: [
-                       'deku.end',
-                       'dc.end',
-                       'jabu.end',
-                       'forest.end',
-                       'fire.end',
-                       'water.end',
-                       'shadow.end',
-                       'spirit.end',
-                       'igc.end',
-               ],
-       },
-       {
-               id: 'boss',
-               bgColor: '#ff00ff',
-               fgColor: '#000000',
-               name: 'Bosses',
-               short: 'Bosses',
-               entrances: [
-                       'gohma',
-                       'kd',
-                       'barinade',
-                       'pg',
-                       'volvo',
-                       'morpha',
-                       'bongo',
-                       'tr',
-                       'ganon',
-               ],
-       },
-];
-
-const ROOMS = [
-       {
-               id: 'trash',
-               bgColor: '#333333',
-               fgColor: '#dddddd',
-               name: 'Trash',
-               short: 'Trash',
-               multi: true,
-       },
-       {
-               id: 'shop',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Shop',
-               short: 'Shop',
-               multi: true,
-       },
-       {
-               id: 'zlf',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'ZL Fairy',
-               short: 'ZL Fairy',
-               multi: true,
-       },
-       {
-               id: 'toti',
-               bgColor: '#ff0000',
-               fgColor: '#000000',
-               name: 'Temple of Time',
-               short: 'Temple of Time',
-       },
-       {
-               id: 'bcb',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Bombchu Bowling',
-               short: 'Bombchu Bow',
-       },
-       {
-               id: 'fish',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Fishing Game',
-               short: 'Fishing',
-       },
-       {
-               id: 'sling',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Slingshot Game',
-               short: 'Slingshot Game',
-       },
-       {
-               id: 'arch',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Archery Game',
-               short: 'Archery Game',
-       },
-       {
-               id: 'tow',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Lon Lon Ranch Tower',
-               short: 'Ranch Tower',
-       },
-       {
-               id: 'chick',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Chicken Game',
-               short: 'Chicken Game',
-       },
-       {
-               id: 'mill',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Windmill',
-               short: 'Windmill',
-       },
-       {
-               id: 'tomb',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Family Tomb',
-               short: 'Family Tomb',
-       },
-       {
-               id: 'ssg',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Sun Song Grave',
-               short: 'Sun Song Grave',
-       },
-       {
-               id: 'mask',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Mask Shop',
-               short: 'Mask Shop',
-       },
-       {
-               id: 'poe',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Big Poe',
-               short: 'Big Poe',
-       },
-       {
-               id: 'thtr',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Mask Theater',
-               short: 'Mask Theater',
-       },
-       {
-               id: 'skull',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Skulltula House',
-               short: 'Skulltula House',
-       },
-       {
-               id: 'tcg',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Treasute Chest Game',
-               short: 'TCG',
-       },
-       {
-               id: 'lab',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Lab Diving',
-               short: 'Lab Dive',
-       },
-       {
-               id: 'tek',
-               bgColor: '#fffd00',
-               fgColor: '#000000',
-               name: 'Tektite Grotto',
-               short: 'Tektite',
-       },
-];
-
-const TYPE_RESTRICTIONS = {
-       OverworldOneWay: [
-               'WarpSong',
-               'BlueWarp',
-               'OwlDrop',
-               'OverworldOneWay',
-               'Overworld',
-               'Extra',
-       ],
-       OwlDrop: [
-               'WarpSong',
-               'BlueWarp',
-               'OwlDrop',
-               'OverworldOneWay',
-               'Overworld',
-               'Extra',
-       ],
-       Spawn: [
-               'Spawn',
-               'WarpSong',
-               'BlueWarp',
-               'OwlDrop',
-               'OverworldOneWay',
-               'Overworld',
-               'Interior',
-               'SpecialInterior',
-               'Extra',
-       ],
-       WarpSong: [
-               'Spawn',
-               'WarpSong',
-               'BlueWarp',
-               'OwlDrop',
-               'OverworldOneWay',
-               'Overworld',
-               'Interior',
-               'SpecialInterior',
-               'Extra',
-       ],
-};
-
-const CONTEXT = React.createContext({});
-
-const useTracker = () => React.useContext(CONTEXT);
-
-const mapEntrance = (area, entrance) => ({
-       ...entrance,
-       id: `${area.id}.${entrance.id}`,
-       area,
-});
-
-const getArea = (id) => {
-       return AREAS.find((area) => area.id === id);
-};
-
-const getEntranceOfArea = (area, entranceId) => {
-       if (!area) return null;
-       if (!area.entrances) return null;
-       const entrance = area.entrances.find((entrance) => entrance.id === entranceId);
-       return mapEntrance(area, entrance);
-};
-
-const getEntrance = (id) => {
-       if (!id) return null;
-       const dotPos = id.indexOf('.');
-       if (dotPos === -1) {
-               return getArea(id);
-       }
-       const areaId = id.substring(0, dotPos);
-       const entranceId = id.substring(dotPos + 1);
-       const area = getArea(areaId);
-       const entrance = getEntranceOfArea(area, entranceId);
-       return entrance;
-};
-
-const getRoom = (id) => {
-       return ROOMS.find((room) => room.id === id);
-};
-
-const entranceShort = (entrance) => {
-       if (!entrance) return null;
-       return entrance.area
-               ? `${entrance.area.short} (${entrance.short})`
-               : entrance.short;
-};
-
-const entranceName = (entrance) => {
-       if (!entrance) return null;
-       return entrance.area
-               ? `[${entrance.area.short}] ${entrance.name}`
-               : entrance.name;
-};
-
-const entranceFull = (entrance) => {
-       if (!entrance) return null;
-       return entrance.area
-               ? `${entrance.area.name} - ${entrance.name}`
-               : entrance.name;
-};
-
-const entranceStyle = (entrance) => {
-       if (!entrance) return null;
-       return {
-               backgroundColor: entrance.bgColor,
-               color: entrance.fgColor,
-       };
-};
-
-const resolvePath = (connections, from) => {
-       if (!connections[from]) return { dst: null, via: [] };
-       const dstEntrance = getEntrance(connections[from]);
-       if (!dstEntrance || !dstEntrance.throughway) return { dst: connections[from], via: [] };
-       const path = resolvePath(connections, dstEntrance.throughway);
-       if (!path.dst) return { dst: connections[from], via: path.via };
-       return { dst: path.dst, via: [connections[from], ...path.via] };
-};
-
-const vecAdd = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
-
-const vecMul = (v, f) => ({ x: v.x * f, y: v.y * f });
-
-const MAPS = AREAS
-       .filter((area) => !!area.map)
-       .map((area) => ({
-               color: area.bgColor,
-               id: area.id,
-               labelPos: area.map.labelPos ? vecAdd(area.map.pos, area.map.labelPos) : null,
-               name: area.name,
-               pos: area.map.pos,
-               short: area.short,
-               size: area.map.size,
-               bg: {
-                       src: area.map.bg.src,
-                       pos: vecAdd(area.map.pos, area.map.bg.off),
-                       size: vecMul(area.map.size, area.map.bg.scale),
-               },
-               entrances: area.entrances
-                       .filter((entrance) => entrance.pos)
-                       .map((entrance) => ({
-                               id: `${area.id}.${entrance.id}`,
-                               name: entranceFull({ ...entrance, area }),
-                               pos: vecAdd(area.map.pos, entrance.pos),
-                               color: entrance.bgColor,
-                       })),
-       }));
-
-const getMapEntrance = (id) => {
-       if (!id) return null;
-       const dotPos = id.indexOf('.');
-       if (dotPos === -1) {
-               return null;
-       }
-       const areaId = id.substring(0, dotPos);
-       const area = MAPS.find((a) => a.id === areaId);
-       if (!area) return null;
-       const entrance = area.entrances.find((e) => e.id === id);
-       return entrance;
-};
-
-const SelectBox = ({ id, name, onChange, options, value }) => {
-       const [open, setOpen] = React.useState(false);
-       const [search, setSearch] = React.useState('');
-
-       const ref = React.useRef();
-       const searchRef = React.useRef();
-
-       const valueEntrance = React.useMemo(() => getEntrance(value) || getRoom(value), [value]);
-
-       const searcher = React.useMemo(() => {
-               return new FuzzySearch(options, ['id', 'name', 'short', 'fullName'], { sort: true });
-       }, [options]);
-
-       const results = React.useMemo(() => {
-               return searcher.search(search);
-       }, [search, searcher]);
-
-       React.useEffect(() => {
-               const handleEventOutside = e => {
-                       if (ref.current && !ref.current.contains(e.target)) {
-                               setOpen(false);
-                       }
-               };
-               document.addEventListener('mousedown', handleEventOutside, true);
-               document.addEventListener('focus', handleEventOutside, true);
-               return () => {
-                       document.removeEventListener('mousedown', handleEventOutside, true);
-                       document.removeEventListener('focus', handleEventOutside, true);
-               };
-       }, []);
-
-       const classNames = ['entrance-select'];
-       if (open) classNames.push('is-open');
-
-       return <div className={classNames.join(' ')} ref={ref}>
-               <input
-                       className="entrance-search"
-                       id={id}
-                       onChange={({ target: { value } }) => setSearch(value)}
-                       onFocus={() => setOpen(true)}
-                       ref={searchRef}
-                       type="search"
-                       value={search}
-               />
-               <div
-                       aria-controls={`${id}.options`}
-                       aria-expanded={open ? 'true' : 'false'}
-                       aria-haspopup={`${id}.options`}
-                       className="entrance-value"
-                       onClick={() => {
-                               setOpen(true);
-                               searchRef.current.focus();
-                               searchRef.current.select();
-                       }}
-                       onContextMenu={(e) => {
-                               if (value) {
-                                       onChange({ target: { name, value: null } });
-                               } else {
-                                       onChange({ target: { name, value: 'trash' } });
-                                       setSearch('');
-                               }
-                               e.preventDefault();
-                               e.stopPropagation();
-                       }}
-                       role="combobox"
-                       style={entranceStyle(valueEntrance)}
-                       title={entranceFull(valueEntrance)}
-               >
-                       {entranceShort(valueEntrance)}
-               </div>
-               <div className="entrance-options" id={`${id}.options`} role="listbox">
-                       {results.map((entrance) =>
-                               <div
-                                       className="entrance-option"
-                                       key={entrance.id}
-                                       onClick={() => {
-                                               onChange({ target: { name, value: entrance.id } });
-                                               setOpen(false);
-                                               setSearch('');
-                                       }}
-                                       role="option"
-                                       style={entranceStyle(entrance)}
-                               >
-                                       {entranceName(entrance)}
-                               </div>
-                       )}
-               </div>
-       </div>;
-};
-
-SelectBox.propTypes = {
-       className: PropTypes.string,
-       id: PropTypes.string,
-       name: PropTypes.string,
-       onChange: PropTypes.func,
-       options: PropTypes.arrayOf(PropTypes.shape({
-               id: PropTypes.string,
-               name: PropTypes.string,
-       })),
-       value: PropTypes.string,
-};
-
-const EntranceGroup = ({ checked = 0, children, group, total = 0 }) => {
-       return <div className="entrance-group">
-               <h2 style={entranceStyle(group)}>
-                       {entranceName(group)}
-                       {checked && checked !== total ?
-                               <span className="checks">{checked}/{total}</span>
-                       : null}
-               </h2>
-               {children}
-       </div>;
-};
-
-EntranceGroup.propTypes = {
-       checked: PropTypes.number,
-       children: PropTypes.node,
-       group: PropTypes.shape({
-               bgColor: PropTypes.string,
-               fgColor: PropTypes.string,
-               name: PropTypes.string,
-       }),
-       total: PropTypes.number,
-};
-
-const EntranceRow = ({ entranceId }) => {
-       const entrance = React.useMemo(() => getEntrance(entranceId), [entranceId]);
-
-       const { connections, entrances, setConnection } = useTracker();
-
-       const options = React.useMemo(() => {
-               if (entrance.type && TYPE_RESTRICTIONS[entrance.type]) {
-                       return entrances.filter((e) =>
-                               e.type && TYPE_RESTRICTIONS[entrance.type].includes(e.type));
-               }
-               return entrances;
-       }, [entrances]);
-
-       const className = React.useMemo(() => {
-               const classNames = ['entrance-row'];
-               if (entrance.spacer) classNames.push('mt-2');
-               if (connections[entrance.id] === 'trash') classNames.push('is-trash');
-               return classNames.join(' ');
-       }, [entrance, connections]);
-
-       return <div className={className}>
-               <label
-                       className="entrance-label"
-                       htmlFor={entranceId}
-                       style={entranceStyle(entrance)}
-                       title={entranceFull(entrance)}
-               >
-                       {entranceShort(entrance)}
-               </label>
-               <SelectBox
-                       id={entranceId}
-                       name={entranceId}
-                       onChange={({ target: { name, value } }) => setConnection(name, value)}
-                       options={options}
-                       value={connections[entranceId]}
-               />
-       </div>;
-};
-
-EntranceRow.propTypes = {
-       entranceId: PropTypes.string,
-};
-
-const MapEntrance = ({ entrance }) => {
-       const {
-               connections,
-               isDragging,
-               onMapEntranceClick,
-               setConnection,
-       } = useTracker();
-
-       const className = React.useMemo(() => {
-               const cs = ['entrance'];
-               if (connections[entrance.id] === 'trash') cs.push('is-trash');
-               if (isDragging(entrance)) cs.push('is-dragging');
-               return cs.join(' ');
-       }, [connections, entrance, isDragging]);
-
-       const path = React.useMemo(() => {
-               return resolvePath(connections, entrance.id);
-       }, [connections, entrance]);
-
-       const destination = React.useMemo(() => {
-               return getEntrance(path.dst) || getRoom(path.dst);
-       }, [path]);
-
-       const onClick = React.useCallback((e) => {
-               onMapEntranceClick(entrance);
-               e.preventDefault();
-               e.stopPropagation();
-       }, [entrance, onMapEntranceClick]);
-
-       const onContext = React.useCallback((e) => {
-               if (connections[entrance.id]) {
-                       setConnection(entrance.id, null);
-               } else {
-                       setConnection(entrance.id, 'trash');
-               }
-               e.preventDefault();
-               e.stopPropagation();
-       }, [connections, entrance, setConnection]);
-
-       const description = React.useMemo(() => {
-               const parts = [
-                       entranceShort(destination),
-                       ...path.via.map((id) => `via ${entranceShort(getEntrance(id))}`),
-                       `@ ${entranceShort(getEntrance(entrance.id))}`,
-               ];
-               return parts.join(' ');
-       }, [destination, entrance, path]);
-
-       if (destination) {
-               return <rect
-                       x={entrance.pos.x - 3}
-                       y={entrance.pos.y - 3}
-                       width={6}
-                       height={6}
-                       className={className}
-                       fill={destination.bgColor}
-                       stroke={destination.fgColor}
-                       onClick={onClick}
-                       onContextMenu={onContext}
-               >
-                       <title>{description}</title>
-               </rect>;
-       }
-
-       return <circle
-               cx={entrance.pos.x}
-               cy={entrance.pos.y}
-               r="3"
-               className={className}
-               fill={entrance.color}
-               stroke="#000000"
-               onClick={onClick}
-               onContextMenu={onContext}
-       >
-               <title>{entrance.name}</title>
-       </circle>;
-};
-
-MapEntrance.propTypes = {
-       entrance: PropTypes.shape({
-               color: PropTypes.string,
-               id: PropTypes.string,
-               name: PropTypes.string,
-               pos: PropTypes.shape({
-                       x: PropTypes.number,
-                       y: PropTypes.number,
-               })
-       }),
-};
-
-const MapConnector = ({ from, id, isTrash, to, via }) => {
-       const { onConnectorClick } = useTracker();
-
-       const className = React.useMemo(() => {
-               const cs = ['connector'];
-               if (isTrash) cs.push('is-trash');
-               if (via.length) cs.push('is-via');
-               return cs.join(' ');
-       }, [isTrash, via]);
-
-       return <line
-               className={className}
-               onClick={() => onConnectorClick(id)}
-               x1={from.pos.x}
-               y1={from.pos.y}
-               x2={to.pos.x}
-               y2={to.pos.y}
-       />;
-};
-
-MapConnector.propTypes = {
-       from: PropTypes.shape({
-               pos: PropTypes.shape({
-                       x: PropTypes.number,
-                       y: PropTypes.number,
-               })
-       }),
-       id: PropTypes.string,
-       isTrash: PropTypes.bool,
-       to: PropTypes.shape({
-               pos: PropTypes.shape({
-                       x: PropTypes.number,
-                       y: PropTypes.number,
-               })
-       }),
-       via: PropTypes.arrayOf(PropTypes.string),
-};
-
-const MapAnnotation = ({ annotation }) => {
-       return <image
-               className="annotation"
-               href={annotation.icon}
-               x={annotation.pos.x}
-               y={annotation.pos.y}
-               width={annotation.size}
-               height={annotation.size}
-       >
-               <title>{annotation.name}</title>
-       </image>;
-};
-
-MapAnnotation.propTypes = {
-       annotation: PropTypes.shape({
-               icon: PropTypes.string,
-               name: PropTypes.string,
-               pos: PropTypes.shape({
-                       x: PropTypes.number,
-                       y: PropTypes.number,
-               }),
-               size: PropTypes.number,
-       }),
-};
-
-const initPrefs = () => {
-       const dump = localStorage.getItem('zootr.mixed-pools-tracker-prefs');
-       if (dump) {
-               return JSON.parse(dump);
-       }
-       return {
-               showConnectors: true,
-               showEntrances: true,
-               showLabels: true,
-               showMaps: false,
-               showWarps: true,
-       };
-};
-
-const MixedPoolsTracker = () => {
-       const { t } = useTranslation();
-
-       const [connections, setConnections] = React.useState({});
-       const [dragging, setDragging] = React.useState(null);
-       const [prefs, setPrefs] = React.useState(initPrefs());
-       const [trashConnectors, setTrashConnectors] = React.useState([]);
-
-       const setConnection = React.useCallback((src, dst) => {
-               setConnections((c) => {
-                       const newConn = { ...c };
-                       const srcEntrance = getEntrance(src);
-                       const oldTarget = getEntrance(c[src]);
-                       if (oldTarget && (!srcEntrance || !srcEntrance.oneway)) {
-                               // unset old connection
-                               newConn[c[src]] = null;
-                       }
-                       newConn[src] = dst;
-                       if (dst && srcEntrance && !srcEntrance.oneway) {
-                               newConn[dst] = src;
-                       }
-                       return newConn;
-               });
-       }, []);
-
-       const entrances = React.useMemo(() => {
-               const options = [];
-               ROOMS.forEach((room) => {
-                       options.push(room);
-               });
-               AREAS.forEach((area) => {
-                       if (area.entrances) {
-                               area.entrances.forEach((entrance) => {
-                                       if (entrance.oneway) return;
-                                       options.push(getEntranceOfArea(area, entrance.id));
-                               });
-                       } else {
-                               options.push(getEntrance(area.id));
-                       }
-               });
-               return options.map((option) => ({
-                       ...option,
-                       fullName: entranceFull(option),
-               }));
-       }, []);
-
-       const isDragging = React.useCallback((entrance) => {
-               return dragging === entrance.id;
-       }, [dragging]);
-
-       const onMapEntranceClick = React.useCallback((entrance) => {
-               if (dragging) {
-                       if (dragging !== entrance.id) {
-                               setConnection(dragging, entrance.id);
-                       }
-                       setDragging(null);
-               } else {
-                       setDragging(entrance.id);
-               }
-       }, [dragging, setConnection, setDragging]);
-
-       const onConnectorClick = React.useCallback((id) => {
-               setTrashConnectors((tc) => {
-                       if (tc.includes(id)) {
-                               return tc.filter((tid) => tid !== id);
-                       }
-                       return [...tc, id];
-               });
-       }, []);
-
-       const context = React.useMemo(() => ({
-               connections,
-               entrances,
-               isDragging,
-               onConnectorClick,
-               onMapEntranceClick,
-               setConnection,
-       }), [
-               connections,
-               entrances,
-               isDragging,
-               onConnectorClick,
-               onMapEntranceClick,
-               setConnection,
-       ]);
-
-       const superGroups = React.useMemo(() => {
-               const sg = [
-                       {
-                               key: 'one',
-                               groups: ['kf', 'lw', 'sfm', 'gy'],
-                       },
-                       {
-                               key: 'two',
-                               groups: ['kak', 'm1', 'm2', 'hc'],
-                       },
-                       {
-                               key: 'three',
-                               groups: ['hf', 'zr', 'zd', 'zf'],
-                       },
-                       {
-                               key: 'four',
-                               groups: ['lh', 'llr', 'gv', 'gf', 'dcol', 'hw'],
-                       },
-                       {
-                               key: 'five',
-                               groups: ['dmc', 'dmt', 'gc', 'tot'],
-                       },
-               ];
-               sg.forEach((superGroup) => {
-                       superGroup.groups = superGroup.groups.map((areaId) => {
-                               const area = getArea(areaId);
-                               const entranceIds = area.entrances.map((entrance) => `${area.id}.${entrance.id}`);
-                               const checked = entranceIds.filter((entranceId) =>
-                                       Object.entries(connections).find(([a, b]) => b && a === entranceId)
-                               ).length;
-                               return {
-                                       area,
-                                       entranceIds,
-                                       checked,
-                                       total: area.entrances.length,
-                               };
-                       });
-               });
-               return sg;
-       }, [connections]);
-
-       const connectors = React.useMemo(() => {
-               const cs = [];
-               Object.entries(connections).forEach(([from]) => {
-                       const fromEntrance = getEntrance(from);
-                       if (!fromEntrance) return;
-                       const path = resolvePath(connections, from);
-                       if (!path.dst) return;
-                       if (from > path.dst && !fromEntrance.oneway) return;
-                       const fromMap = getMapEntrance(from);
-                       if (!fromMap) return;
-                       const toMap = getMapEntrance(path.dst);
-                       if (!toMap) return;
-                       const id = `${fromMap.id}-${toMap.id}`;
-                       const isTrash = trashConnectors.includes(id);
-                       cs.push({
-                               id,
-                               from: fromMap,
-                               to: toMap,
-                               via: path.via,
-                               isTrash,
-                       });
-               });
-               return cs;
-       }, [connections, trashConnectors]);
-
-       const annotations = React.useMemo(() => {
-               const annotate = [
-                       'songs.minuet',
-                       'songs.bolero',
-                       'songs.serenade',
-                       'songs.nocturne',
-                       'songs.requiem',
-                       'songs.prelude',
-                       'spawns.child',
-                       'spawns.adult',
-                       'owls.lhowl',
-                       'owls.dmtowl',
-               ];
-               const ans = [];
-               annotate.forEach((id) => {
-                       if (!connections[id]) return;
-                       const srcEntrance = getEntrance(id);
-                       if (!srcEntrance) return;
-                       const dstMap = getMapEntrance(connections[id]);
-                       if (!dstMap) return;
-                       ans.push({
-                               icon: srcEntrance.icon,
-                               name: srcEntrance.name,
-                               pos: vecAdd(dstMap.pos, dstMap.annotationOffset || { x: 0, y: 0 }),
-                               size: srcEntrance.iconSize || 8,
-                       });
-               });
-               return ans;
-       }, [connections]);
-
-       const save = React.useCallback(() => {
-               try {
-                       const dump = JSON.stringify({ connections, trashConnectors });
-                       localStorage.setItem('zootr.mixed-pools-tracker-save', dump);
-                       toastr.success(t('general.saveSuccess'));
-               } catch (e) {
-                       toastr.error(t('general.saveError'));
-                       console.error(e);
-               }
-       }, [connections, t, trashConnectors]);
-
-       const load = React.useCallback(() => {
-               try {
-                       const dump = localStorage.getItem('zootr.mixed-pools-tracker-save');
-                       if (!dump) {
-                               toastr.error(t('general.loadError'));
-                               return;
-                       }
-                       const { connections, trashConnectors } = JSON.parse(dump);
-                       if (connections) {
-                               setConnections(connections);
-                       } else {
-                               setConnections({});
-                       }
-                       if (trashConnectors) {
-                               setTrashConnectors(trashConnectors);
-                       } else {
-                               setTrashConnectors([]);
-                       }
-                       toastr.success(t('general.loadSuccess'));
-               } catch (e) {
-                       toastr.error(t('general.loadError'));
-                       console.error(e);
-               }
-       }, [setConnections, t]);
-
-       const reset = React.useCallback(() => {
-               try {
-                       setConnections({});
-                       setTrashConnectors([]);
-                       toastr.success(t('general.resetSuccess'));
-               } catch (e) {
-                       toastr.error(t('general.resetError'));
-               }
-       }, [t]);
-
-       const togglePref = React.useCallback((which) => {
-               setPrefs((oldPrefs) => {
-                       const newPrefs = {
-                               ...oldPrefs,
-                               [which]: !oldPrefs[which],
-                       };
-                       localStorage.setItem('zootr.mixed-pools-tracker-prefs', JSON.stringify(newPrefs));
-                       return newPrefs;
-               });
-       }, []);
-
-       return <CONTEXT.Provider value={context}>
-               <div className="mixed-pools-tracker">
-                       <div className="columns">
-                               {superGroups.map((sg) =>
-                                       <div className="column" key={sg.key}>
-                                               {sg.groups.map(group =>
-                                                       <EntranceGroup
-                                                               checked={group.checked}
-                                                               group={group.area}
-                                                               key={group.area.id}
-                                                               total={group.total}
-                                                       >
-                                                               {group.entranceIds.map((entranceId) =>
-                                                                       <EntranceRow entranceId={entranceId} key={entranceId} />
-                                                               )}
-                                                       </EntranceGroup>
-                                               )}
-                                       </div>
-                               )}
-                       </div>
-                       <div className="columns">
-                               {DUNGEONS.map((area) =>
-                                       <div className="column" key={area.id}>
-                                               <EntranceGroup group={area}>
-                                                       {area.entrances.map((entranceId) =>
-                                                               <EntranceRow
-                                                                       entranceId={entranceId}
-                                                                       key={entranceId}
-                                                               />
-                                                       )}
-                                               </EntranceGroup>
-                                       </div>
-                               )}
-                               <div className="column">
-                                       {AREAS.slice(43, 44).map((area) =>
-                                               <EntranceGroup group={area} key={area.id}>
-                                                       {area.entrances.map((entrance) =>
-                                                               <EntranceRow
-                                                                       entranceId={`${area.id}.${entrance.id}`}
-                                                                       key={entrance.id}
-                                                               />
-                                                       )}
-                                               </EntranceGroup>
-                                       )}
-                               </div>
-                               <div className="column">
-                                       {AREAS.slice(44, 46).map((area) =>
-                                               <EntranceGroup group={area} key={area.id}>
-                                                       {area.entrances.map((entrance) =>
-                                                               <EntranceRow
-                                                                       entranceId={`${area.id}.${entrance.id}`}
-                                                                       key={entrance.id}
-                                                               />
-                                                       )}
-                                               </EntranceGroup>
-                                       )}
-                               </div>
-                       </div>
-                       <div className="map mt-5">
-                               <svg
-                                       viewBox="0 0 500 370"
-                                       onClick={() => { setDragging(null); }}
-                                       onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
-                               >
-                                       <g className="background">
-                                               {MAPS.map((map) =>
-                                                       <g className="area" key={map.id} title={map.name}>
-                                                               <image
-                                                                       href={map.bg.src}
-                                                                       pointerEvents="none"
-                                                                       x={map.bg.pos.x} y={map.bg.pos.y}
-                                                                       width={map.bg.size.x}
-                                                                       style={{ opacity: prefs.showMaps ? 1 : 0.25 }}
-                                                               />
-                                                               {map.labelPos && prefs.showLabels ?
-                                                                       <text
-                                                                               className="area-label"
-                                                                               x={map.labelPos.x}
-                                                                               y={map.labelPos.y}
-                                                                               fill={map.color}
-                                                                       >
-                                                                               {map.short}
-                                                                       </text>
-                                                               : null}
-                                                       </g>
-                                               )}
-                                       </g>
-                                       {prefs.showConnectors ?
-                                               <g title="connectors">
-                                                       {connectors.map((c) =>
-                                                               <MapConnector
-                                                                       key={c.id}
-                                                                       from={c.from}
-                                                                       id={c.id}
-                                                                       isTrash={c.isTrash}
-                                                                       to={c.to}
-                                                                       via={c.via}
-                                                               />
-                                                       )}
-                                               </g>
-                                       : null}
-                                       {MAPS.map((map) =>
-                                               <g className="area" key={map.id} title={map.name}>
-                                                       {prefs.showEntrances ? map.entrances.map((entrance) =>
-                                                               <MapEntrance key={entrance.id} entrance={entrance} />
-                                                       ) : null}
-                                               </g>
-                                       )}
-                                       {prefs.showWarps ?
-                                               <g title="anotations">
-                                                       {annotations.map((a) =>
-                                                               <MapAnnotation
-                                                                       key={`${a.id}`}
-                                                                       annotation={a}
-                                                               />
-                                                       )}
-                                               </g>
-                                       : null}
-                               </svg>
-                       </div>
-                       <div className="menu-bar">
-                               <div className="button-bar">
-                                       <Button
-                                               onClick={() => togglePref('showConnectors')}
-                                               size="sm"
-                                               variant={prefs.showConnectors ? 'secondary' : 'outline-secondary'}
-                                       >
-                                               Connectors
-                                       </Button>
-                                       <Button
-                                               onClick={() => togglePref('showEntrances')}
-                                               size="sm"
-                                               variant={prefs.showEntrances ? 'secondary' : 'outline-secondary'}
-                                       >
-                                               Entrances
-                                       </Button>
-                                       <Button
-                                               onClick={() => togglePref('showLabels')}
-                                               size="sm"
-                                               variant={prefs.showLabels ? 'secondary' : 'outline-secondary'}
-                                       >
-                                               Labels
-                                       </Button>
-                                       <Button
-                                               onClick={() => togglePref('showMaps')}
-                                               size="sm"
-                                               variant={prefs.showMaps ? 'secondary' : 'outline-secondary'}
-                                       >
-                                               Maps
-                                       </Button>
-                                       <Button
-                                               onClick={() => togglePref('showWarps')}
-                                               size="sm"
-                                               variant={prefs.showWarps ? 'secondary' : 'outline-secondary'}
-                                       >
-                                               Warps
-                                       </Button>
-                               </div>
-                       <div className="button-bar">
-                               <Button
-                                       onClick={save}
-                                       size="sm"
-                                       title={t('button.save')}
-                                       variant="outline-secondary"
-                               >
-                                       <Icon.SAVE title="" />
-                               </Button>
-                               <Button
-                                       onClick={load}
-                                       size="sm"
-                                       title={t('button.load')}
-                                       variant="outline-secondary"
-                               >
-                                       <Icon.LOAD title="" />
-                               </Button>
-                               <Button
-                                       onClick={reset}
-                                       size="sm"
-                                       title={t('button.reset')}
-                                       variant="outline-secondary"
-                               >
-                                       <Icon.RESET title="" />
-                               </Button>
-                       </div>
-                       </div>
-               </div>
-       </CONTEXT.Provider>;
-};
-
-export default MixedPoolsTracker;
diff --git a/resources/js/components/zootr/MixedPoolsTracker.jsx b/resources/js/components/zootr/MixedPoolsTracker.jsx
new file mode 100644 (file)
index 0000000..ee7f954
--- /dev/null
@@ -0,0 +1,3288 @@
+import FuzzySearch from 'fuzzy-search';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import Icon from '../common/Icon';
+
+const AREAS = [
+       {
+               id: 'kf',
+               bgColor: '#6aa84f',
+               fgColor: '#000000',
+               name: 'Kokiri Forest',
+               short: 'KF',
+               map: {
+                       pos: { x: 210, y: 240 },
+                       size: { x: 150, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/kf.png',
+                               off: { x: 0, y: 0 },
+                               scale: 1.3,
+                       },
+                       labelPos: { x: 90, y: 70 },
+               },
+               entrances: [
+                       {
+                               id: 'bro',
+                               bgColor: '#b6d7a8',
+                               fgColor: '#000000',
+                               name: 'Know-It-All Brothers\' House',
+                               short: 'Bros',
+                               type: 'Interior',
+                               pos: { x: 13, y: 57 },
+                       },
+                       {
+                               id: 'link',
+                               bgColor: '#b6d7a8',
+                               fgColor: '#000000',
+                               name: 'Link\'s House',
+                               short: 'Links',
+                               type: 'SpecialInterior',
+                               pos: { x: 36, y: 74 },
+                       },
+                       {
+                               id: 'sariah',
+                               bgColor: '#b6d7a8',
+                               fgColor: '#000000',
+                               name: 'Sariah\'s House',
+                               short: 'Sariahs',
+                               type: 'Interior',
+                               pos: { x: 47, y: 66 },
+                       },
+                       {
+                               id: 'twin',
+                               bgColor: '#b6d7a8',
+                               fgColor: '#000000',
+                               name: 'House of Twins',
+                               short: 'Twins',
+                               type: 'Interior',
+                               pos: { x: 58, y: 64 },
+                       },
+                       {
+                               id: 'shop',
+                               bgColor: '#b6d7a8',
+                               fgColor: '#000000',
+                               name: 'Kokiri Shop',
+                               short: 'Shop',
+                               type: 'Interior',
+                               pos: { x: 53, y: 42 },
+                       },
+                       {
+                               id: 'mido',
+                               bgColor: '#b6d7a8',
+                               fgColor: '#000000',
+                               name: 'Great Mido\'s House',
+                               short: 'Mido',
+                               type: 'Interior',
+                               pos: { x: 28, y: 38 },
+                       },
+                       {
+                               id: 'deku',
+                               bgColor: '#ead1dc',
+                               fgColor: '#000000',
+                               name: 'Deku Tree',
+                               short: 'Deku',
+                               spacer: true,
+                               type: 'Dungeon',
+                               pos: { x: 114, y: 26 },
+                       },
+                       {
+                               id: 'storms',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Storms Grotto',
+                               short: 'Storms Grotto',
+                               type: 'Grotto',
+                               pos: { x: 25, y: 25 },
+                       },
+                       {
+                               id: 'hf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Hyrule Field',
+                               short: 'HF',
+                               type: 'Overworld',
+                               pos: { x: 4, y: 45 },
+                       },
+                       {
+                               id: 'lw',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Lost Woods',
+                               short: 'LW',
+                               type: 'Overworld',
+                               pos: { x: 31, y: 21 },
+                       },
+               ],
+       },
+       {
+               id: 'lw',
+               bgColor: '#38761d',
+               fgColor: '#000000',
+               name: 'Lost Woods',
+               short: 'LW',
+               map: {
+                       pos: { x: 370, y: 220 },
+                       size: { x: 75, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/lw.png',
+                               off: { x: 0, y: 0 },
+                               scale: 1.3,
+                       },
+                       labelPos: { x: 40, y: 85 },
+               },
+               entrances: [
+                       {
+                               id: 'gcg',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Goron City Grotto',
+                               short: 'GC Grotto',
+                               type: 'Grotto',
+                               pos: { x: 45, y: 40 },
+                       },
+                       {
+                               id: 'tg',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Theater Grotto',
+                               short: 'Theater Grotto',
+                               type: 'Grotto',
+                               pos: { x: 32, y: 25 },
+                       },
+                       {
+                               id: 'sfmg',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'SFM Grotto',
+                               short: 'SFM Grotto',
+                               type: 'Grotto',
+                               pos: { x: 40, y: 12 },
+                       },
+                       {
+                               id: 'kf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Kokiri Forest',
+                               short: 'KF',
+                               spacer: true,
+                               type: 'Overworld',
+                               pos: { x: 31, y: 60 },
+                       },
+                       {
+                               id: 'gc',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Goron City',
+                               short: 'GC',
+                               type: 'Overworld',
+                               pos: { x: 44, y: 33 },
+                       },
+                       {
+                               id: 'zr',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Zora\'s River',
+                               short: 'ZR',
+                               type: 'Overworld',
+                               pos: { x: 68, y: 38 },
+                       },
+                       {
+                               id: 'sfm',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Sacred Forest Maedows',
+                               short: 'SFM',
+                               type: 'Overworld',
+                               pos: { x: 45, y: 6 },
+                       },
+               ],
+       },
+       {
+               id: 'sfm',
+               bgColor: '#274e13',
+               fgColor: '#000000',
+               name: 'Sacred Forest Maedows',
+               short: 'SFM',
+               map: {
+                       pos: { x: 460, y: 220 },
+                       size: { x: 35, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/sfm.png',
+                               off: { x: -20, y: 0 },
+                               scale: 4.3,
+                       },
+                       labelPos: { x: 15, y: 110 },
+               },
+               entrances: [
+                       {
+                               id: 'forest',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Forest Temple',
+                               short: 'Forest Temple',
+                               type: 'Dungeon',
+                               pos: { x: 18, y: 5 },
+                       },
+                       {
+                               id: 'wolf',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Wolf Grotto',
+                               short: 'Wolf Grotto',
+                               type: 'Grotto',
+                               pos: { x: 14, y: 87 },
+                       },
+                       {
+                               id: 'fairy',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Fairy Grotto',
+                               short: 'Fairy Grotto',
+                               type: 'Grotto',
+                               pos: { x: 18, y: 60 },
+                       },
+                       {
+                               id: 'storms',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Storms Grotto',
+                               short: 'Storms Grotto',
+                               type: 'Grotto',
+                               pos: { x: 25, y: 18 },
+                       },
+                       {
+                               id: 'lw',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Lost Woods',
+                               short: 'LW',
+                               type: 'Overworld',
+                               pos: { x: 14, y: 95 },
+                       },
+               ],
+       },
+       {
+               id: 'gy',
+               bgColor: '#a64d79',
+               fgColor: '#000000',
+               name: 'Graveyard',
+               short: 'Grave',
+               map: {
+                       pos: { x: 310, y: 170 },
+                       size: { x: 100, y: 35, },
+                       bg: {
+                               src: '/media/oot/minimap/gy.png',
+                               off: { x: 0, y: -5 },
+                               scale: 1.5,
+                       },
+                       labelPos: { x: 65, y: 35 },
+               },
+               entrances: [
+                       {
+                               id: 'dh',
+                               bgColor: '#c27ba0',
+                               fgColor: '#000000',
+                               name: 'Dampe Hut',
+                               short: 'Dampe Hut',
+                               type: 'Interior',
+                               pos: { x: 22, y: 26 },
+                       },
+                       {
+                               id: 'shadow',
+                               bgColor: '#c27ba0',
+                               fgColor: '#000000',
+                               name: 'Shadow Temple',
+                               short: 'Shadow',
+                               type: 'Dungeon',
+                               pos: { x: 90, y: 16 },
+                       },
+                       {
+                               id: 'shield',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Shield Grave',
+                               short: 'Shield Grave',
+                               type: 'Grave',
+                               pos: { x: 30, y: 19 },
+                       },
+                       {
+                               id: 'race',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Dampe Race',
+                               short: 'Dampe Race',
+                               type: 'Grave',
+                               pos: { x: 25, y: 7 },
+                       },
+                       {
+                               id: 'sun',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Sun Song Grave',
+                               short: 'Sun Song Gr',
+                               type: 'Grave',
+                               pos: { x: 41, y: 21 },
+                       },
+                       {
+                               id: 'family',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Family Tomb',
+                               short: 'Family Tomb',
+                               type: 'Grave',
+                               pos: { x: 52, y: 15 },
+                       },
+                       {
+                               id: 'kak',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Kakariko',
+                               short: 'Kak',
+                               type: 'Overworld',
+                               pos: { x: 5, y: 20 },
+                       },
+               ],
+       },
+       {
+               id: 'kak',
+               bgColor: '#ff9900',
+               fgColor: '#000000',
+               name: 'Kakariko Village',
+               short: 'Kak',
+               map: {
+                       pos: { x: 190, y: 140 },
+                       size: { x: 125, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/kak.png',
+                               off: { x: -7, y: 0 },
+                               scale: 1.7,
+                       },
+                       labelPos: { x: 20, y: 95 },
+               },
+               entrances: [
+                       {
+                               id: 'talon',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Carpenter Boss House',
+                               short: 'Talons',
+                               type: 'Interior',
+                               pos: { x: 66, y: 51 },
+                       },
+                       {
+                               id: 'skull',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Skulltula House',
+                               short: 'Skulltula',
+                               type: 'Interior',
+                               pos: { x: 58, y: 73 },
+                       },
+                       {
+                               id: 'impaf',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Impa Front',
+                               short: 'Impa Front',
+                               type: 'Interior',
+                               pos: { x: 58, y: 92 },
+                       },
+                       {
+                               id: 'impab',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Impa Back',
+                               short: 'Impa Back',
+                               type: 'Interior',
+                               pos: { x: 71, y: 91 },
+                       },
+                       {
+                               id: 'shield',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Shield Shop (Bazaar)',
+                               short: 'Shield Shop',
+                               type: 'Interior',
+                               pos: { x: 60, y: 28 },
+                       },
+                       {
+                               id: 'potion',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Potion Shop',
+                               short: 'Potion Shop',
+                               type: 'SpecialInterior',
+                               pos: { x: 70, y: 28 },
+                       },
+                       {
+                               id: 'back',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Shop Back',
+                               short: 'Shop Back',
+                               type: 'SpecialInterior',
+                               pos: { x: 80, y: 28 },
+                       },
+                       {
+                               id: 'witch',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Witch',
+                               short: 'Witch',
+                               type: 'Interior',
+                               pos: { x: 84, y: 46 },
+                       },
+                       {
+                               id: 'arch',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Archery',
+                               short: 'Archery',
+                               type: 'Interior',
+                               pos: { x: 72, y: 67 },
+                       },
+                       {
+                               id: 'mill',
+                               bgColor: '#f9cb9c',
+                               fgColor: '#000000',
+                               name: 'Windmill',
+                               short: 'Windmill',
+                               type: 'SpecialInterior',
+                               pos: { x: 96, y: 59 },
+                       },
+                       {
+                               id: 'botw',
+                               bgColor: '#a64d79',
+                               fgColor: '#000000',
+                               name: 'Bottom of the Well',
+                               short: 'Bottom Well',
+                               spacer: true,
+                               type: 'Dungeon',
+                               pos: { x: 84, y: 59 },
+                       },
+                       {
+                               id: 'open',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Open Grotto',
+                               short: 'Open Grotto',
+                               type: 'Grotto',
+                               pos: { x: 86, y: 37 },
+                       },
+                       {
+                               id: 'redead',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Redead Grotto',
+                               short: 'Redead Grotto',
+                               type: 'Grotto',
+                               pos: { x: 58, y: 57 },
+                       },
+                       {
+                               id: 'hf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Hyrule Field',
+                               short: 'HF',
+                               type: 'Overworld',
+                               pos: { x: 10, y: 74 },
+                       },
+                       {
+                               id: 'dmt',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Death Mountain Trail',
+                               short: 'DMT',
+                               type: 'Overworld',
+                               pos: { x: 67, y: 8 },
+                       },
+                       {
+                               id: 'gy',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Graveyard',
+                               short: 'Grave',
+                               type: 'Overworld',
+                               pos: { x: 112, y: 83 },
+                       },
+               ],
+       },
+       {
+               id: 'm1',
+               bgColor: '#9900ff',
+               fgColor: '#000000',
+               name: 'Market 1',
+               short: 'M1',
+               map: {
+                       pos: { x: 100, y: 80 },
+                       size: { x: 100, y: 60, },
+                       bg: {
+                               src: '/media/oot/minimap/m1.png',
+                               off: { x: 0, y: -6 },
+                               scale: 1.0,
+                       },
+                       labelPos: { x: 38, y: 30 },
+               },
+               entrances: [
+                       {
+                               id: 'shield',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Shield Shop',
+                               short: 'Shield Shop',
+                               type: 'Interior',
+                               pos: { x: 81, y: 36 },
+                       },
+                       {
+                               id: 'potion',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Potion Shop',
+                               short: 'Potion Shop',
+                               type: 'Interior',
+                               pos: { x: 81, y: 27 },
+                       },
+                       {
+                               id: 'mask',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Mask Shop',
+                               short: 'Mask Shop',
+                               type: 'Interior',
+                               pos: { x: 75, y: 14 },
+                       },
+                       {
+                               id: 'sling',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Slingshot Game',
+                               short: 'Sling Game',
+                               type: 'Interior',
+                               pos: { x: 60, y: 14 },
+                       },
+                       {
+                               id: 'chuu',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Bombchu Bowling',
+                               short: 'Bombchu',
+                               type: 'Interior',
+                               pos: { x: 54, y: 30 },
+                       },
+                       {
+                               id: 'tcg',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Treasure Chest Game',
+                               short: 'TCG',
+                               type: 'Interior',
+                               pos: { x: 52, y: 48 },
+                       },
+                       {
+                               id: 'alleyl',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Backalley Left',
+                               short: 'Alley L',
+                               type: 'Overworld',
+                               pos: { x: 26, y: 48 },
+                       },
+                       {
+                               id: 'alleyr',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Backalley Right',
+                               short: 'Alley R',
+                               type: 'Overworld',
+                               pos: { x: 18, y: 10 },
+                       },
+                       {
+                               id: 'tot',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Temple of Time',
+                               short: 'ToT',
+                               type: 'Overworld',
+                               pos: { x: 92, y: 15 },
+                       },
+                       {
+                               id: 'hc',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Hyrule Castle',
+                               short: 'HC',
+                               type: 'Overworld',
+                               pos: { x: 67, y: 7 },
+                       },
+                       {
+                               id: 'm2',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Market 2',
+                               short: 'M2',
+                               type: 'Overworld',
+                               pos: { x: 66, y: 50 },
+                       },
+               ],
+       },
+       {
+               id: 'm2',
+               bgColor: '#9900ff',
+               fgColor: '#000000',
+               name: 'Market 2',
+               short: 'M2',
+               map: {
+                       pos: { x: 205, y: 85 },
+                       size: { x: 18, y: 50, },
+                       bg: {
+                               src: '/media/oot/minimap/m2.png',
+                               off: { x: -3, y: -3 },
+                               scale: 3.6,
+                       },
+                       labelPos: { x: -2, y: 25 },
+               },
+               entrances: [
+                       {
+                               id: 'bp',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Big Poe',
+                               short: 'Big Poe',
+                               type: 'Interior',
+                               pos: { x: 15.5, y: 32 },
+                       },
+                       {
+                               id: 'hf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Hyrule Field',
+                               short: 'HF',
+                               type: 'Overworld',
+                               pos: { x: 8.5, y: 47.5 },
+                       },
+                       {
+                               id: 'm1',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Market 1',
+                               short: 'M1',
+                               type: 'Overworld',
+                               pos: { x: 8.5, y: 3 },
+                       },
+               ],
+       },
+       {
+               id: 'hc',
+               bgColor: '#bf9000',
+               fgColor: '#000000',
+               name: 'Hyrule Castle',
+               short: 'HC',
+               map: {
+                       pos: { x: 100, y: 0 },
+                       size: { x: 120, y: 70, },
+                       bg: {
+                               src: '/media/oot/minimap/hc-gc.png',
+                               off: { x: 0, y: 0 },
+                               scale: 1.2,
+                       },
+                       labelPos: { x: 25, y: 65 },
+               },
+               entrances: [
+                       {
+                               id: 'hcfairy',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Hyrule Castle Fairy',
+                               short: 'HC Fairy',
+                               type: 'Interior',
+                               pos: { x: 61, y: 23 },
+                       },
+                       {
+                               id: 'gfairy',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Ganon\'s Castle Fairy',
+                               short: 'Ganon Fairy',
+                               type: 'Interior',
+                               pos: { x: 116, y: 51 },
+                       },
+                       {
+                               id: 'igc',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Inside Ganon\'s Castle',
+                               short: 'IGC',
+                               type: 'DungeonSpecial',
+                               pos: { x: 83, y: 30 },
+                       },
+                       {
+                               id: 'storms',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Storms Grotto',
+                               short: 'Storms Grotto',
+                               type: 'Grotto',
+                               pos: { x: 30, y: 29 },
+                       },
+                       {
+                               id: 'm1',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Market 1',
+                               short: 'M1',
+                               type: 'Overworld',
+                               pos: { x: 60, y: 66 },
+                       },
+               ],
+       },
+       {
+               id: 'hf',
+               bgColor: '#674ea7',
+               fgColor: '#000000',
+               name: 'Hyrule Field',
+               short: 'HF',
+               map: {
+                       pos: { x: 90, y: 150 },
+                       size: { x: 100, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/hf.png',
+                               off: { x: -16, y: 0 },
+                               scale: 1.8,
+                       },
+                       labelPos: { x: 100, y: 10 },
+               },
+               entrances: [
+                       {
+                               id: 'destiny',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Destiny Grotto',
+                               short: 'Destiny Grotto',
+                               type: 'Grotto',
+                               pos: { x: 33, y: 7 },
+                       },
+                       {
+                               id: 'tektite',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Tektite Grotto',
+                               short: 'Tektite',
+                               type: 'Grotto',
+                               pos: { x: 30, y: 23 },
+                       },
+                       {
+                               id: 'nw',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Northwest Grotto (near Market)',
+                               short: 'NW Grotto',
+                               type: 'Grotto',
+                               pos: { x: 48, y: 13 },
+                       },
+                       {
+                               id: 'nk',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Grotto near Kakariko',
+                               short: 'near Kak Gro',
+                               type: 'Grotto',
+                               pos: { x: 68, y: 8 },
+                       },
+                       {
+                               id: 'se',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Southeast Grotto',
+                               short: 'SE Grotto',
+                               type: 'Grotto',
+                               pos: { x: 55, y: 78 },
+                       },
+                       {
+                               id: 'open',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Open Grotto',
+                               short: 'Open Grotto',
+                               type: 'Grotto',
+                               pos: { x: 35, y: 86 },
+                       },
+                       {
+                               id: 'sg',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'South Grotto',
+                               short: 'South Grotto',
+                               type: 'Grotto',
+                               pos: { x: 28, y: 87 },
+                       },
+                       {
+                               id: 'cow',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Cow Grotto',
+                               short: 'Cow Grotto',
+                               type: 'Grotto',
+                               pos: { x: 13, y: 47 },
+                       },
+                       {
+                               id: 'town',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Town',
+                               short: 'Town',
+                               spacer: true,
+                               type: 'Overworld',
+                               pos: { x: 57, y: 10 },
+                       },
+                       {
+                               id: 'llr',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Lon Lon Ranch',
+                               short: 'LLR',
+                               type: 'Overworld',
+                               pos: { x: 45, y: 44 },
+                       },
+                       {
+                               id: 'kak',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Kakariko',
+                               short: 'Kak',
+                               type: 'Overworld',
+                               pos: { x: 80, y: 10 },
+                       },
+                       {
+                               id: 'zr',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Zora\'s River',
+                               short: 'ZR',
+                               type: 'Overworld',
+                               pos: { x: 94, y: 30 },
+                       },
+                       {
+                               id: 'kf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Kokiri Forest',
+                               short: 'KF',
+                               type: 'Overworld',
+                               pos: { x: 87, y: 57 },
+                       },
+                       {
+                               id: 'lh',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Lake Hylia',
+                               short: 'LH',
+                               type: 'Overworld',
+                               pos: { x: 25, y: 95 },
+                       },
+                       {
+                               id: 'gv',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Gerudo Valley',
+                               short: 'GV',
+                               type: 'Overworld',
+                               pos: { x: 6, y: 51 },
+                       },
+               ],
+       },
+       {
+               id: 'zr',
+               bgColor: '#3c78d8',
+               fgColor: '#000000',
+               name: 'Zora\'s River',
+               short: 'ZR',
+               map: {
+                       pos: { x: 330, y: 100 },
+                       size: { x: 100, y: 60, },
+                       bg: {
+                               src: '/media/oot/minimap/zr.png',
+                               off: { x: 0, y: 0 },
+                               scale: 1.3,
+                       },
+                       labelPos: { x: 60, y: 40 },
+               },
+               entrances: [
+                       {
+                               id: 'storms',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Storms Grotto',
+                               short: 'Storms Grotto',
+                               type: 'Grotto',
+                               pos: { x: 8, y: 28 },
+                       },
+                       {
+                               id: 'open',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Open Grotto',
+                               short: 'Open Grotto',
+                               type: 'Grotto',
+                               pos: { x: 36, y: 32 },
+                       },
+                       {
+                               id: 'boulder',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Boulder Grotto',
+                               short: 'Boulder Grotto',
+                               type: 'Grotto',
+                               pos: { x: 40, y: 24 },
+                       },
+                       {
+                               id: 'hf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Hyrule Field',
+                               short: 'HF',
+                               spacer: true,
+                               type: 'Overworld',
+                               pos: { x: 10, y: 52 },
+                       },
+                       {
+                               id: 'lw',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Lost Woods',
+                               short: 'LW',
+                               type: 'Overworld',
+                               pos: { x: 90.5, y: 18 },
+                       },
+                       {
+                               id: 'zd',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Zora\'s Domain',
+                               short: 'ZD',
+                               type: 'Overworld',
+                               pos: { x: 96, y: 9 },
+                       },
+               ],
+       },
+       {
+               id: 'zd',
+               bgColor: '#3c78d8',
+               fgColor: '#000000',
+               name: 'Zora\'s Domain',
+               short: 'ZD',
+               map: {
+                       pos: { x: 410, y: 100 },
+                       size: { x: 80, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/zd.png',
+                               off: { x: -15, y: -5 },
+                               scale: 2.3,
+                       },
+                       labelPos: { x: 18, y: 40 },
+               },
+               entrances: [
+                       {
+                               id: 'shop',
+                               bgColor: '#aac2f1',
+                               fgColor: '#000000',
+                               name: 'Shop',
+                               short: 'Shop',
+                               type: 'Interior',
+                               pos: { x: 55, y: 85 },
+                       },
+                       {
+                               id: 'storms',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Storms Grotto',
+                               short: 'Storms Grotto',
+                               type: 'Grotto',
+                               pos: { x: 16, y: 64 },
+                       },
+                       {
+                               id: 'zr',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Zora\'s River',
+                               short: 'ZR',
+                               type: 'Overworld',
+                               pos: { x: 6, y: 73 },
+                       },
+                       {
+                               id: 'lh',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Lake Hylia',
+                               short: 'LH',
+                               type: 'Overworld',
+                               pos: { x: 35, y: 72 },
+                       },
+                       {
+                               id: 'zf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Zora\'s Fountain',
+                               short: 'ZF',
+                               type: 'Overworld',
+                               pos: { x: 58, y: 15 },
+                       },
+               ],
+       },
+       {
+               id: 'zf',
+               bgColor: '#3c78d8',
+               fgColor: '#000000',
+               name: 'Zora\'s Fountain',
+               short: 'ZF',
+               map: {
+                       pos: { x: 410, y: 0 },
+                       size: { x: 90, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/zf.png',
+                               off: { x: -5, y: 0 },
+                               scale: 2.1,
+                       },
+                       labelPos: { x: 60, y: 35 },
+               },
+               entrances: [
+                       {
+                               id: 'wall',
+                               bgColor: '#aac2f1',
+                               fgColor: '#000000',
+                               name: 'Fairy Wall',
+                               short: 'Fairy Wall',
+                               type: 'Interior',
+                               pos: { x: 61, y: 90 },
+                       },
+                       {
+                               id: 'jabu',
+                               bgColor: '#ead1dc',
+                               fgColor: '#000000',
+                               name: 'Jabu Jabu\'s Belly',
+                               short: 'Jabu',
+                               type: 'Dungeon',
+                               pos: { x: 34, y: 38 },
+                       },
+                       {
+                               id: 'ice',
+                               bgColor: '#a64d79',
+                               fgColor: '#000000',
+                               name: 'Ice Cavern',
+                               short: 'Ice Cavern',
+                               type: 'Dungeon',
+                               pos: { x: 52, y: 10 },
+                       },
+                       {
+                               id: 'zd',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Zora\'s Domain',
+                               short: 'ZD',
+                               type: 'Overworld',
+                               pos: { x: 12, y: 55 },
+                       },
+               ],
+       },
+       {
+               id: 'lh',
+               bgColor: '#4a86e8',
+               fgColor: '#000000',
+               name: 'Lake Hylia',
+               short: 'LH',
+               map: {
+                       pos: { x: 0, y: 265 },
+                       size: { x: 90, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/lh.png',
+                               off: { x: -15, y: -5 },
+                               scale: 1.9,
+                       },
+                       labelPos: { x: 70, y: 65 },
+               },
+               entrances: [
+                       {
+                               id: 'dive',
+                               bgColor: '#cfe2f3',
+                               fgColor: '#000000',
+                               name: 'Lab Diving',
+                               short: 'Lab Dive',
+                               type: 'Interior',
+                               pos: { x: 30, y: 43 },
+                       },
+                       {
+                               id: 'fishing',
+                               bgColor: '#cfe2f3',
+                               fgColor: '#000000',
+                               name: 'Fishing Game',
+                               short: 'Fishing',
+                               type: 'Interior',
+                               pos: { x: 68, y: 43 },
+                       },
+                       {
+                               id: 'water',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Water Temple',
+                               short: 'Water Temple',
+                               type: 'Dungeon',
+                               pos: { x: 45, y: 65 },
+                       },
+                       {
+                               id: 'owl',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Owl Grotto',
+                               short: 'Owl Grotto',
+                               type: 'Grotto',
+                               pos: { x: 24, y: 65 },
+                       },
+                       {
+                               id: 'hf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Hyrule Field',
+                               short: 'HF',
+                               type: 'Overworld',
+                               pos: { x: 35, y: 5 },
+                       },
+                       {
+                               id: 'zd',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Zora\'s Domain',
+                               short: 'ZD',
+                               type: 'Overworld',
+                               pos: { x: 45, y: 40 },
+                       },
+               ],
+       },
+       {
+               id: 'llr',
+               bgColor: '#f1c232',
+               fgColor: '#000000',
+               name: 'Lon Lon Ranch',
+               short: 'LLR',
+               map: {
+                       pos: { x: 100, y: 260 },
+                       size: { x: 80, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/llr.png',
+                               off: { x: -22, y: -6 },
+                               scale: 2.6,
+                       },
+                       labelPos: { x: 40, y: 50 },
+               },
+               entrances: [
+                       {
+                               id: 'chicken',
+                               bgColor: '#ffd966',
+                               fgColor: '#000000',
+                               name: 'Chicken Game',
+                               short: 'Chicken',
+                               type: 'Interior',
+                               pos: { x: 54, y: 13 },
+                       },
+                       {
+                               id: 'stable',
+                               bgColor: '#ffd966',
+                               fgColor: '#000000',
+                               name: 'Stable',
+                               short: 'Stable',
+                               type: 'Interior',
+                               pos: { x: 50, y: 19 },
+                       },
+                       {
+                               id: 'tower',
+                               bgColor: '#ffd966',
+                               fgColor: '#000000',
+                               name: 'Tower',
+                               short: 'Tower',
+                               type: 'Interior',
+                               pos: { x: 17, y: 82 },
+                       },
+                       {
+                               id: 'grotto',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Grotto',
+                               short: 'Grotto',
+                               type: 'Grotto',
+                               pos: { x: 60, y: 85 },
+                       },
+                       {
+                               id: 'hf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Hyrule Field',
+                               short: 'HF',
+                               type: 'Overworld',
+                               pos: { x: 60, y: 5 },
+                       },
+               ],
+       },
+       {
+               id: 'gv',
+               bgColor: '#b45f06',
+               fgColor: '#000000',
+               name: 'Gerudo Valley',
+               short: 'GV',
+               map: {
+                       pos: { x: 0, y: 190 },
+                       size: { x: 100, y: 75, },
+                       bg: {
+                               src: '/media/oot/minimap/gv.png',
+                               off: { x: -17, y: -5 },
+                               scale: 1.9,
+                       },
+                       labelPos: { x: 20, y: 55 },
+               },
+               entrances: [
+                       {
+                               id: 'tent',
+                               bgColor: '#b45f06',
+                               fgColor: '#000000',
+                               name: 'Tent',
+                               short: 'Tent',
+                               type: 'Interior',
+                               pos: { x: 44, y: 33 },
+                       },
+                       {
+                               id: 'str2',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Strength 2 Grotto',
+                               short: 'Str2 Grotto',
+                               type: 'Grotto',
+                               pos: { x: 60, y: 60 },
+                       },
+                       {
+                               id: 'storms',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Storms Grotto',
+                               short: 'Storms Grotto',
+                               type: 'Grotto',
+                               pos: { x: 39, y: 27 },
+                       },
+                       {
+                               id: 'hf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Hyrule Field',
+                               short: 'HF',
+                               type: 'Overworld',
+                               pos: { x: 93, y: 45 },
+                       },
+                       {
+                               id: 'gf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Gerudo Fortress',
+                               short: 'GF',
+                               type: 'Overworld',
+                               pos: { x: 6, y: 22 },
+                       },
+                       {
+                               id: 'wf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Waterfall',
+                               short: 'Waterfall',
+                               oneway: true,
+                               type: 'OverworldOneWay',
+                               pos: { x: 58, y: 69 },
+                       },
+               ],
+       },
+       {
+               id: 'gf',
+               bgColor: '#b45f06',
+               fgColor: '#000000',
+               name: 'Gerudo Fortress',
+               short: 'GF',
+               map: {
+                       pos: { x: 0, y: 90 },
+                       size: { x: 90, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/gf.png',
+                               off: { x: -5, y: -35 },
+                               scale: 2.5,
+                       },
+                       labelPos: { x: 15, y: 50 },
+               },
+               entrances: [
+                       {
+                               id: 'gtg',
+                               bgColor: '#a64d79',
+                               fgColor: '#000000',
+                               name: 'Gerudo Training Grounds',
+                               short: 'GTG',
+                               type: 'Dungeon',
+                               pos: { x: 48, y: 60 },
+                       },
+                       {
+                               id: 'storms',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Storms Grotto',
+                               short: 'Storms Grotto',
+                               type: 'Grotto',
+                               pos: { x: 52, y: 47 },
+                       },
+                       {
+                               id: 'gv',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Gerudo Valley',
+                               short: 'GV',
+                               type: 'Overworld',
+                               pos: { x: 43, y: 93 },
+                       },
+                       {
+                               id: 'hw',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Haunted Wasteland',
+                               short: 'Waste',
+                               type: 'Overworld',
+                               pos: { x: 7, y: 13 },
+                       },
+               ],
+       },
+       {
+               id: 'dcol',
+               bgColor: '#f1c232',
+               fgColor: '#000000',
+               name: 'Desert Colossus',
+               short: 'DCol',
+               map: {
+                       pos: { x: 0, y: 0 },
+                       size: { x: 100, y: 90, },
+                       bg: {
+                               src: '/media/oot/minimap/dcol.png',
+                               off: { x: 0, y: -2 },
+                               scale: 2.0,
+                       },
+                       labelPos: { x: 50, y: 50 },
+               },
+               entrances: [
+                       {
+                               id: 'spirit',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Spirit Temple',
+                               short: 'Spirit',
+                               type: 'Dungeon',
+                               pos: { x: 10, y: 43.5 },
+                       },
+                       {
+                               id: 'fairy',
+                               bgColor: '#ffd966',
+                               fgColor: '#000000',
+                               name: 'Fairy',
+                               short: 'Fairy',
+                               type: 'Interior',
+                               pos: { x: 62, y: 21 },
+                       },
+                       {
+                               id: 'str2',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Strength 2 Grotto',
+                               short: 'Str2 Grotto',
+                               type: 'Grotto',
+                               pos: { x: 32, y: 28 },
+                       },
+                       {
+                               id: 'hw',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Haunted Wasteland',
+                               short: 'Waste',
+                               type: 'Overworld',
+                               pos: { x: 85, y: 40 },
+                       },
+               ],
+       },
+       {
+               id: 'hw',
+               bgColor: '#f1c232',
+               fgColor: '#000000',
+               name: 'Haunted Wasteland',
+               short: 'Waste',
+               entrances: [
+                       {
+                               id: 'gf',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Gerudo Fortress',
+                               short: 'GF',
+                               type: 'Overworld',
+                               throughway: 'hw.dcol',
+                       },
+                       {
+                               id: 'dcol',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Desert Colossus',
+                               short: 'DCol',
+                               type: 'Overworld',
+                               throughway: 'hw.gf',
+                       },
+               ],
+       },
+       {
+               id: 'dmc',
+               bgColor: '#ff0000',
+               fgColor: '#000000',
+               name: 'Death Mountain Crater',
+               short: 'DMC',
+               map: {
+                       pos: { x: 320, y: 0 },
+                       size: { x: 90, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/dmc.png',
+                               off: { x: -15, y: -3 },
+                               scale: 1.9,
+                       },
+                       labelPos: { x: 52, y: 62 },
+               },
+               entrances: [
+                       {
+                               id: 'fairy',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Fairy',
+                               short: 'Fairy',
+                               type: 'Interior',
+                               pos: { x: 17, y: 68 },
+                       },
+                       {
+                               id: 'fire',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Fire Temple',
+                               short: 'Fire',
+                               type: 'Dungeon',
+                               pos: { x: 48, y: 6 },
+                       },
+                       {
+                               id: 'boulder',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Boulder Grotto',
+                               short: 'Boulder Gro',
+                               type: 'Grotto',
+                               pos: { x: 49, y: 86 },
+                       },
+                       {
+                               id: 'hammer',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Hammer Grotto',
+                               short: 'Hammer Gro',
+                               type: 'Grotto',
+                               pos: { x: 12, y: 35 },
+                       },
+                       {
+                               id: 'gc',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Goron City',
+                               short: 'GC',
+                               type: 'Overworld',
+                               pos: { x: 7, y: 46 },
+                       },
+                       {
+                               id: 'dmt',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Death Mountain Trail',
+                               short: 'DMT',
+                               type: 'Overworld',
+                               pos: { x: 25, y: 90 },
+                       },
+               ],
+       },
+       {
+               id: 'dmt',
+               bgColor: '#ff0000',
+               fgColor: '#000000',
+               name: 'Death Mountain Trail',
+               short: 'DMT',
+               map: {
+                       pos: { x: 280, y: 70 },
+                       size: { x: 40, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/dmt.png',
+                               off: { x: -12, y: 0 },
+                               scale: 3.8,
+                       },
+                       labelPos: { x: -10, y: 50 },
+               },
+               entrances: [
+                       {
+                               id: 'dc',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Dodongo\'s Cavern',
+                               short: 'DC',
+                               type: 'Dungeon',
+                               pos: { x: 8, y: 53 },
+                       },
+                       {
+                               id: 'fairy',
+                               bgColor: '#ea9999',
+                               fgColor: '#000000',
+                               name: 'Fairy',
+                               short: 'Fairy',
+                               type: 'Interior',
+                               pos: { x: 22, y: 10 },
+                       },
+                       {
+                               id: 'storms',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Storms Grotto',
+                               short: 'Storms Grotto',
+                               type: 'Grotto',
+                               pos: { x: 23, y: 48 },
+                       },
+                       {
+                               id: 'cow',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Cow Grotto',
+                               short: 'Cow Grotto',
+                               type: 'Grotto',
+                               pos: { x: 20, y: 57 },
+                       },
+                       {
+                               id: 'kak',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Kakariko',
+                               short: 'Kak',
+                               spacer: true,
+                               type: 'Overworld',
+                               pos: { x: 15, y: 94 },
+                       },
+                       {
+                               id: 'gc',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Goron City',
+                               short: 'GC',
+                               type: 'Overworld',
+                               pos: { x: 24, y: 40 },
+                       },
+                       {
+                               id: 'dmc',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Death Mountain Crater',
+                               short: 'DMC',
+                               type: 'Overworld',
+                               pos: { x: 29, y: 5 },
+                       },
+               ],
+       },
+       {
+               id: 'gc',
+               bgColor: '#ff0000',
+               fgColor: '#000000',
+               name: 'Goron City',
+               short: 'GC',
+               map: {
+                       pos: { x: 220, y: 0 },
+                       size: { x: 90, y: 100, },
+                       bg: {
+                               src: '/media/oot/minimap/gc.png',
+                               off: { x: -2, y: 0 },
+                               scale: 2.0,
+                       },
+                       labelPos: { x: 25, y: 105 },
+               },
+               entrances: [
+                       {
+                               id: 'shop',
+                               bgColor: '#ea9999',
+                               fgColor: '#000000',
+                               name: 'Shop',
+                               short: 'Shop',
+                               type: 'Interior',
+                               pos: { x: 42, y: 50 },
+                       },
+                       {
+                               id: 'times',
+                               bgColor: '#b7b7b7',
+                               fgColor: '#000000',
+                               name: 'Times Grotto',
+                               short: 'Times Grotto',
+                               type: 'Grotto',
+                               pos: { x: 80, y: 17 },
+                       },
+                       {
+                               id: 'dmt',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Death Mountain Trail',
+                               short: 'DMT',
+                               type: 'Overworld',
+                               pos: { x: 50.5, y: 95 },
+                       },
+                       {
+                               id: 'lw',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Lost Woods',
+                               short: 'LW',
+                               type: 'Overworld',
+                               pos: { x: 64, y: 93 },
+                       },
+                       {
+                               id: 'dmc',
+                               bgColor: '#ff6d01',
+                               fgColor: '#000000',
+                               name: 'Death Mountain Crater',
+                               short: 'DMC',
+                               type: 'Overworld',
+                               pos: { x: 51.5, y: 5 },
+                       },
+               ],
+       },
+       {
+               id: 'tot',
+               bgColor: '#ffffff',
+               fgColor: '#000000',
+               name: 'Temple of Time',
+               short: 'ToT',
+               entrances: [
+                       {
+                               id: 'temple',
+                               bgColor: '#b4a7d6',
+                               fgColor: '#000000',
+                               name: 'Temple of Time',
+                               short: 'Temple',
+                               type: 'SpecialInterior',
+                       },
+                       {
+                               id: 'm1',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Market 1',
+                               short: 'M1',
+                               type: 'Overworld',
+                       },
+               ],
+       },
+       {
+               id: 'deku',
+               bgColor: '#ead1dc',
+               fgColor: '#000000',
+               name: 'Deku Tree',
+               short: 'Deku',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#ead1dc',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               type: 'Dungeon',
+                               throughway: 'deku.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#ead1dc',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               type: 'ChildBoss',
+                               throughway: 'deku.main',
+                       },
+               ],
+       },
+       {
+               id: 'gohma',
+               bgColor: '#ead1dc',
+               fgColor: '#000000',
+               name: 'Gohma',
+               short: 'Gohma',
+               type: 'ChildBoss',
+       },
+       {
+               id: 'dc',
+               bgColor: '#ead1dc',
+               fgColor: '#000000',
+               name: 'Dodongo\'s Cavern',
+               short: 'DC',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#ead1dc',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               type: 'Dungeon',
+                               throughway: 'dc.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#ead1dc',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               type: 'ChildBoss',
+                               throughway: 'dc.main',
+                       },
+               ],
+       },
+       {
+               id: 'kd',
+               bgColor: '#ead1dc',
+               fgColor: '#000000',
+               name: 'King Dodongo',
+               short: 'Dodongo',
+               type: 'ChildBoss',
+       },
+       {
+               id: 'jabu',
+               bgColor: '#ead1dc',
+               fgColor: '#000000',
+               name: 'Jabu Jabu\'s Belly',
+               short: 'Jabu',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#ead1dc',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               type: 'Dungeon',
+                               throughway: 'jabu.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#ead1dc',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               type: 'ChildBoss',
+                               throughway: 'jabu.main',
+                       },
+               ],
+       },
+       {
+               id: 'barinade',
+               bgColor: '#ead1dc',
+               fgColor: '#000000',
+               name: 'Barinade',
+               short: 'Barinade',
+               type: 'ChildBoss',
+       },
+       {
+               id: 'forest',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Forest Temple',
+               short: 'Forest',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               spacer: true,
+                               type: 'Dungeon',
+                               throughway: 'forest.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               spacer: true,
+                               type: 'AdultBoss',
+                               throughway: 'forest.main',
+                       },
+               ],
+       },
+       {
+               id: 'pg',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Phantom Ganon',
+               short: 'PG',
+               spacer: true,
+               type: 'AdultBoss',
+       },
+       {
+               id: 'fire',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Fire Temple',
+               short: 'Fire',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               type: 'Dungeon',
+                               throughway: 'fire.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               type: 'AdultBoss',
+                               throughway: 'fire.main',
+                       },
+               ],
+       },
+       {
+               id: 'volvo',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Volvagia',
+               short: 'Volvagia',
+               type: 'AdultBoss',
+       },
+       {
+               id: 'water',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Water Temple',
+               short: 'Water',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               type: 'Dungeon',
+                               throughway: 'water.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               type: 'AdultBoss',
+                               throughway: 'water.main',
+                       },
+               ],
+       },
+       {
+               id: 'morpha',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Morpha',
+               short: 'Morpha',
+               type: 'AdultBoss',
+       },
+       {
+               id: 'shadow',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Shadow Temple',
+               short: 'Shadow',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               type: 'Dungeon',
+                               throughway: 'shadow.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               type: 'AdultBoss',
+                               throughway: 'shadow.main',
+                       },
+               ],
+       },
+       {
+               id: 'bongo',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Bongo Bongo',
+               short: 'Bongo Bongo',
+               type: 'AdultBoss',
+       },
+       {
+               id: 'spirit',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Spirit Temple',
+               short: 'Spirit',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               type: 'Dungeon',
+                               throughway: 'spirit.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#d5a6bd',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               type: 'AdultBoss',
+                               throughway: 'spirit.main',
+                       },
+               ],
+       },
+       {
+               id: 'tr',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Twinrova',
+               short: 'Twinrova',
+               type: 'AdultBoss',
+       },
+       {
+               id: 'igc',
+               bgColor: '#d5a6bd',
+               fgColor: '#000000',
+               name: 'Inside Ganon\'s Castle',
+               short: 'IGC',
+               entrances: [
+                       {
+                               id: 'main',
+                               bgColor: '#a64d79',
+                               fgColor: '#000000',
+                               name: 'Main',
+                               short: 'Main',
+                               spacer: true,
+                               type: 'Dungeon',
+                               throughway: 'igc.end',
+                       },
+                       {
+                               id: 'end',
+                               bgColor: '#a64d79',
+                               fgColor: '#000000',
+                               name: 'End',
+                               short: 'End',
+                               spacer: true,
+                               type: 'SpecialBoss',
+                               throughway: 'igc.main',
+                       },
+               ],
+       },
+       {
+               id: 'ganon',
+               bgColor: '#a64d79',
+               fgColor: '#000000',
+               name: 'Ganon',
+               short: 'Ganon',
+               spacer: true,
+               type: 'SpecialBoss',
+       },
+       {
+               id: 'botw',
+               bgColor: '#a64d79',
+               fgColor: '#000000',
+               name: 'Bottom of the Well',
+               short: 'Bottom Well',
+               type: 'Dungeon',
+       },
+       {
+               id: 'ice',
+               bgColor: '#a64d79',
+               fgColor: '#000000',
+               name: 'Ice Cavern',
+               short: 'Ice Cavern',
+               type: 'Dungeon',
+       },
+       {
+               id: 'gtg',
+               bgColor: '#a64d79',
+               fgColor: '#000000',
+               name: 'Gerudo Training Grounds',
+               short: 'GTG',
+               type: 'Dungeon',
+       },
+       {
+               id: 'songs',
+               bgColor: '#000000',
+               fgColor: '#ffffff',
+               name: 'Warp Songs',
+               short: 'Songs',
+               entrances: [
+                       {
+                               id: 'minuet',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Minuet of Forest',
+                               short: 'Minuet',
+                               oneway: true,
+                               type: 'WarpSong',
+                               icon: '/media/oot/icons/song-minuet.png',
+                       },
+                       {
+                               id: 'bolero',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Bolero of Fire',
+                               short: 'Bolero',
+                               oneway: true,
+                               type: 'WarpSong',
+                               icon: '/media/oot/icons/song-bolero.png',
+                       },
+                       {
+                               id: 'serenade',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Serenade of Water',
+                               short: 'Serenade',
+                               oneway: true,
+                               type: 'WarpSong',
+                               icon: '/media/oot/icons/song-serenade.png',
+                       },
+                       {
+                               id: 'nocturne',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Nocturne of Shadow',
+                               short: 'Nocturne',
+                               oneway: true,
+                               type: 'WarpSong',
+                               icon: '/media/oot/icons/song-nocturne.png',
+                       },
+                       {
+                               id: 'requiem',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Requiem of Spirit',
+                               short: 'Requiem',
+                               oneway: true,
+                               type: 'WarpSong',
+                               icon: '/media/oot/icons/song-requiem.png',
+                       },
+                       {
+                               id: 'prelude',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Prelude of Light',
+                               short: 'Prelude',
+                               oneway: true,
+                               type: 'WarpSong',
+                               icon: '/media/oot/icons/song-prelude.png',
+                       },
+               ],
+       },
+       {
+               id: 'spawns',
+               bgColor: '#000000',
+               fgColor: '#ffffff',
+               name: 'Spawns',
+               short: 'Spawns',
+               entrances: [
+                       {
+                               id: 'child',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Child Spawn',
+                               short: 'Child',
+                               oneway: true,
+                               type: 'Spawn',
+                               icon: '/media/oot/icons/link-child.png',
+                               iconSize: 10,
+                       },
+                       {
+                               id: 'adult',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Adult Spawn',
+                               short: 'Adult',
+                               oneway: true,
+                               type: 'Spawn',
+                               icon: '/media/oot/icons/link-adult.png',
+                       },
+               ],
+       },
+       {
+               id: 'owls',
+               bgColor: '#000000',
+               fgColor: '#ffffff',
+               name: 'Owl Drops',
+               short: 'Owls',
+               entrances: [
+                       {
+                               id: 'lhowl',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Lake Hylia Owl',
+                               short: 'LH Owl',
+                               oneway: true,
+                               type: 'OwlDrop',
+                               icon: '/media/oot/icons/owl-lake.png',
+                       },
+                       {
+                               id: 'dmtowl',
+                               bgColor: '#38761d',
+                               fgColor: '#ffffff',
+                               name: 'Death Mountain Trail Owl',
+                               short: 'Trail Owl',
+                               oneway: true,
+                               type: 'OwlDrop',
+                               icon: '/media/oot/icons/owl-trail.png',
+                       },
+               ],
+       },
+];
+
+const DUNGEONS = [
+       {
+               id: 'd1',
+               bgColor: '#ff00ff',
+               fgColor: '#000000',
+               name: 'Dungeon Entrances',
+               short: 'Dungeon Entrances',
+               type: 'main',
+               entrances: [
+                       'deku.main',
+                       'dc.main',
+                       'jabu.main',
+                       'forest.main',
+                       'fire.main',
+                       'water.main',
+                       'shadow.main',
+                       'spirit.main',
+                       'igc.main',
+                       'botw',
+                       'ice',
+                       'gtg',
+               ],
+       },
+       {
+               id: 'd2',
+               bgColor: '#ff00ff',
+               fgColor: '#000000',
+               name: 'Dungeon Exits',
+               short: 'Dungeon Exits',
+               type: 'end',
+               entrances: [
+                       'deku.end',
+                       'dc.end',
+                       'jabu.end',
+                       'forest.end',
+                       'fire.end',
+                       'water.end',
+                       'shadow.end',
+                       'spirit.end',
+                       'igc.end',
+               ],
+       },
+       {
+               id: 'boss',
+               bgColor: '#ff00ff',
+               fgColor: '#000000',
+               name: 'Bosses',
+               short: 'Bosses',
+               entrances: [
+                       'gohma',
+                       'kd',
+                       'barinade',
+                       'pg',
+                       'volvo',
+                       'morpha',
+                       'bongo',
+                       'tr',
+                       'ganon',
+               ],
+       },
+];
+
+const ROOMS = [
+       {
+               id: 'trash',
+               bgColor: '#333333',
+               fgColor: '#dddddd',
+               name: 'Trash',
+               short: 'Trash',
+               multi: true,
+       },
+       {
+               id: 'shop',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Shop',
+               short: 'Shop',
+               multi: true,
+       },
+       {
+               id: 'zlf',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'ZL Fairy',
+               short: 'ZL Fairy',
+               multi: true,
+       },
+       {
+               id: 'toti',
+               bgColor: '#ff0000',
+               fgColor: '#000000',
+               name: 'Temple of Time',
+               short: 'Temple of Time',
+       },
+       {
+               id: 'bcb',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Bombchu Bowling',
+               short: 'Bombchu Bow',
+       },
+       {
+               id: 'fish',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Fishing Game',
+               short: 'Fishing',
+       },
+       {
+               id: 'sling',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Slingshot Game',
+               short: 'Slingshot Game',
+       },
+       {
+               id: 'arch',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Archery Game',
+               short: 'Archery Game',
+       },
+       {
+               id: 'tow',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Lon Lon Ranch Tower',
+               short: 'Ranch Tower',
+       },
+       {
+               id: 'chick',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Chicken Game',
+               short: 'Chicken Game',
+       },
+       {
+               id: 'mill',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Windmill',
+               short: 'Windmill',
+       },
+       {
+               id: 'tomb',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Family Tomb',
+               short: 'Family Tomb',
+       },
+       {
+               id: 'ssg',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Sun Song Grave',
+               short: 'Sun Song Grave',
+       },
+       {
+               id: 'mask',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Mask Shop',
+               short: 'Mask Shop',
+       },
+       {
+               id: 'poe',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Big Poe',
+               short: 'Big Poe',
+       },
+       {
+               id: 'thtr',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Mask Theater',
+               short: 'Mask Theater',
+       },
+       {
+               id: 'skull',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Skulltula House',
+               short: 'Skulltula House',
+       },
+       {
+               id: 'tcg',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Treasute Chest Game',
+               short: 'TCG',
+       },
+       {
+               id: 'lab',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Lab Diving',
+               short: 'Lab Dive',
+       },
+       {
+               id: 'tek',
+               bgColor: '#fffd00',
+               fgColor: '#000000',
+               name: 'Tektite Grotto',
+               short: 'Tektite',
+       },
+];
+
+const TYPE_RESTRICTIONS = {
+       OverworldOneWay: [
+               'WarpSong',
+               'BlueWarp',
+               'OwlDrop',
+               'OverworldOneWay',
+               'Overworld',
+               'Extra',
+       ],
+       OwlDrop: [
+               'WarpSong',
+               'BlueWarp',
+               'OwlDrop',
+               'OverworldOneWay',
+               'Overworld',
+               'Extra',
+       ],
+       Spawn: [
+               'Spawn',
+               'WarpSong',
+               'BlueWarp',
+               'OwlDrop',
+               'OverworldOneWay',
+               'Overworld',
+               'Interior',
+               'SpecialInterior',
+               'Extra',
+       ],
+       WarpSong: [
+               'Spawn',
+               'WarpSong',
+               'BlueWarp',
+               'OwlDrop',
+               'OverworldOneWay',
+               'Overworld',
+               'Interior',
+               'SpecialInterior',
+               'Extra',
+       ],
+};
+
+const CONTEXT = React.createContext({});
+
+const useTracker = () => React.useContext(CONTEXT);
+
+const mapEntrance = (area, entrance) => ({
+       ...entrance,
+       id: `${area.id}.${entrance.id}`,
+       area,
+});
+
+const getArea = (id) => {
+       return AREAS.find((area) => area.id === id);
+};
+
+const getEntranceOfArea = (area, entranceId) => {
+       if (!area) return null;
+       if (!area.entrances) return null;
+       const entrance = area.entrances.find((entrance) => entrance.id === entranceId);
+       return mapEntrance(area, entrance);
+};
+
+const getEntrance = (id) => {
+       if (!id) return null;
+       const dotPos = id.indexOf('.');
+       if (dotPos === -1) {
+               return getArea(id);
+       }
+       const areaId = id.substring(0, dotPos);
+       const entranceId = id.substring(dotPos + 1);
+       const area = getArea(areaId);
+       const entrance = getEntranceOfArea(area, entranceId);
+       return entrance;
+};
+
+const getRoom = (id) => {
+       return ROOMS.find((room) => room.id === id);
+};
+
+const entranceShort = (entrance) => {
+       if (!entrance) return null;
+       return entrance.area
+               ? `${entrance.area.short} (${entrance.short})`
+               : entrance.short;
+};
+
+const entranceName = (entrance) => {
+       if (!entrance) return null;
+       return entrance.area
+               ? `[${entrance.area.short}] ${entrance.name}`
+               : entrance.name;
+};
+
+const entranceFull = (entrance) => {
+       if (!entrance) return null;
+       return entrance.area
+               ? `${entrance.area.name} - ${entrance.name}`
+               : entrance.name;
+};
+
+const entranceStyle = (entrance) => {
+       if (!entrance) return null;
+       return {
+               backgroundColor: entrance.bgColor,
+               color: entrance.fgColor,
+       };
+};
+
+const resolvePath = (connections, from) => {
+       if (!connections[from]) return { dst: null, via: [] };
+       const dstEntrance = getEntrance(connections[from]);
+       if (!dstEntrance || !dstEntrance.throughway) return { dst: connections[from], via: [] };
+       const path = resolvePath(connections, dstEntrance.throughway);
+       if (!path.dst) return { dst: connections[from], via: path.via };
+       return { dst: path.dst, via: [connections[from], ...path.via] };
+};
+
+const vecAdd = (a, b) => ({ x: a.x + b.x, y: a.y + b.y });
+
+const vecMul = (v, f) => ({ x: v.x * f, y: v.y * f });
+
+const MAPS = AREAS
+       .filter((area) => !!area.map)
+       .map((area) => ({
+               color: area.bgColor,
+               id: area.id,
+               labelPos: area.map.labelPos ? vecAdd(area.map.pos, area.map.labelPos) : null,
+               name: area.name,
+               pos: area.map.pos,
+               short: area.short,
+               size: area.map.size,
+               bg: {
+                       src: area.map.bg.src,
+                       pos: vecAdd(area.map.pos, area.map.bg.off),
+                       size: vecMul(area.map.size, area.map.bg.scale),
+               },
+               entrances: area.entrances
+                       .filter((entrance) => entrance.pos)
+                       .map((entrance) => ({
+                               id: `${area.id}.${entrance.id}`,
+                               name: entranceFull({ ...entrance, area }),
+                               pos: vecAdd(area.map.pos, entrance.pos),
+                               color: entrance.bgColor,
+                       })),
+       }));
+
+const getMapEntrance = (id) => {
+       if (!id) return null;
+       const dotPos = id.indexOf('.');
+       if (dotPos === -1) {
+               return null;
+       }
+       const areaId = id.substring(0, dotPos);
+       const area = MAPS.find((a) => a.id === areaId);
+       if (!area) return null;
+       const entrance = area.entrances.find((e) => e.id === id);
+       return entrance;
+};
+
+const SelectBox = ({ id, name, onChange, options, value }) => {
+       const [open, setOpen] = React.useState(false);
+       const [search, setSearch] = React.useState('');
+
+       const ref = React.useRef();
+       const searchRef = React.useRef();
+
+       const valueEntrance = React.useMemo(() => getEntrance(value) || getRoom(value), [value]);
+
+       const searcher = React.useMemo(() => {
+               return new FuzzySearch(options, ['id', 'name', 'short', 'fullName'], { sort: true });
+       }, [options]);
+
+       const results = React.useMemo(() => {
+               return searcher.search(search);
+       }, [search, searcher]);
+
+       React.useEffect(() => {
+               const handleEventOutside = e => {
+                       if (ref.current && !ref.current.contains(e.target)) {
+                               setOpen(false);
+                       }
+               };
+               document.addEventListener('mousedown', handleEventOutside, true);
+               document.addEventListener('focus', handleEventOutside, true);
+               return () => {
+                       document.removeEventListener('mousedown', handleEventOutside, true);
+                       document.removeEventListener('focus', handleEventOutside, true);
+               };
+       }, []);
+
+       const classNames = ['entrance-select'];
+       if (open) classNames.push('is-open');
+
+       return <div className={classNames.join(' ')} ref={ref}>
+               <input
+                       className="entrance-search"
+                       id={id}
+                       onChange={({ target: { value } }) => setSearch(value)}
+                       onFocus={() => setOpen(true)}
+                       ref={searchRef}
+                       type="search"
+                       value={search}
+               />
+               <div
+                       aria-controls={`${id}.options`}
+                       aria-expanded={open ? 'true' : 'false'}
+                       aria-haspopup={`${id}.options`}
+                       className="entrance-value"
+                       onClick={() => {
+                               setOpen(true);
+                               searchRef.current.focus();
+                               searchRef.current.select();
+                       }}
+                       onContextMenu={(e) => {
+                               if (value) {
+                                       onChange({ target: { name, value: null } });
+                               } else {
+                                       onChange({ target: { name, value: 'trash' } });
+                                       setSearch('');
+                               }
+                               e.preventDefault();
+                               e.stopPropagation();
+                       }}
+                       role="combobox"
+                       style={entranceStyle(valueEntrance)}
+                       title={entranceFull(valueEntrance)}
+               >
+                       {entranceShort(valueEntrance)}
+               </div>
+               <div className="entrance-options" id={`${id}.options`} role="listbox">
+                       {results.map((entrance) =>
+                               <div
+                                       className="entrance-option"
+                                       key={entrance.id}
+                                       onClick={() => {
+                                               onChange({ target: { name, value: entrance.id } });
+                                               setOpen(false);
+                                               setSearch('');
+                                       }}
+                                       role="option"
+                                       style={entranceStyle(entrance)}
+                               >
+                                       {entranceName(entrance)}
+                               </div>
+                       )}
+               </div>
+       </div>;
+};
+
+SelectBox.propTypes = {
+       className: PropTypes.string,
+       id: PropTypes.string,
+       name: PropTypes.string,
+       onChange: PropTypes.func,
+       options: PropTypes.arrayOf(PropTypes.shape({
+               id: PropTypes.string,
+               name: PropTypes.string,
+       })),
+       value: PropTypes.string,
+};
+
+const EntranceGroup = ({ checked = 0, children, group, total = 0 }) => {
+       return <div className="entrance-group">
+               <h2 style={entranceStyle(group)}>
+                       {entranceName(group)}
+                       {checked && checked !== total ?
+                               <span className="checks">{checked}/{total}</span>
+                       : null}
+               </h2>
+               {children}
+       </div>;
+};
+
+EntranceGroup.propTypes = {
+       checked: PropTypes.number,
+       children: PropTypes.node,
+       group: PropTypes.shape({
+               bgColor: PropTypes.string,
+               fgColor: PropTypes.string,
+               name: PropTypes.string,
+       }),
+       total: PropTypes.number,
+};
+
+const EntranceRow = ({ entranceId }) => {
+       const entrance = React.useMemo(() => getEntrance(entranceId), [entranceId]);
+
+       const { connections, entrances, setConnection } = useTracker();
+
+       const options = React.useMemo(() => {
+               if (entrance.type && TYPE_RESTRICTIONS[entrance.type]) {
+                       return entrances.filter((e) =>
+                               e.type && TYPE_RESTRICTIONS[entrance.type].includes(e.type));
+               }
+               return entrances;
+       }, [entrances]);
+
+       const className = React.useMemo(() => {
+               const classNames = ['entrance-row'];
+               if (entrance.spacer) classNames.push('mt-2');
+               if (connections[entrance.id] === 'trash') classNames.push('is-trash');
+               return classNames.join(' ');
+       }, [entrance, connections]);
+
+       return <div className={className}>
+               <label
+                       className="entrance-label"
+                       htmlFor={entranceId}
+                       style={entranceStyle(entrance)}
+                       title={entranceFull(entrance)}
+               >
+                       {entranceShort(entrance)}
+               </label>
+               <SelectBox
+                       id={entranceId}
+                       name={entranceId}
+                       onChange={({ target: { name, value } }) => setConnection(name, value)}
+                       options={options}
+                       value={connections[entranceId]}
+               />
+       </div>;
+};
+
+EntranceRow.propTypes = {
+       entranceId: PropTypes.string,
+};
+
+const MapEntrance = ({ entrance }) => {
+       const {
+               connections,
+               isDragging,
+               onMapEntranceClick,
+               setConnection,
+       } = useTracker();
+
+       const className = React.useMemo(() => {
+               const cs = ['entrance'];
+               if (connections[entrance.id] === 'trash') cs.push('is-trash');
+               if (isDragging(entrance)) cs.push('is-dragging');
+               return cs.join(' ');
+       }, [connections, entrance, isDragging]);
+
+       const path = React.useMemo(() => {
+               return resolvePath(connections, entrance.id);
+       }, [connections, entrance]);
+
+       const destination = React.useMemo(() => {
+               return getEntrance(path.dst) || getRoom(path.dst);
+       }, [path]);
+
+       const onClick = React.useCallback((e) => {
+               onMapEntranceClick(entrance);
+               e.preventDefault();
+               e.stopPropagation();
+       }, [entrance, onMapEntranceClick]);
+
+       const onContext = React.useCallback((e) => {
+               if (connections[entrance.id]) {
+                       setConnection(entrance.id, null);
+               } else {
+                       setConnection(entrance.id, 'trash');
+               }
+               e.preventDefault();
+               e.stopPropagation();
+       }, [connections, entrance, setConnection]);
+
+       const description = React.useMemo(() => {
+               const parts = [
+                       entranceShort(destination),
+                       ...path.via.map((id) => `via ${entranceShort(getEntrance(id))}`),
+                       `@ ${entranceShort(getEntrance(entrance.id))}`,
+               ];
+               return parts.join(' ');
+       }, [destination, entrance, path]);
+
+       if (destination) {
+               return <rect
+                       x={entrance.pos.x - 3}
+                       y={entrance.pos.y - 3}
+                       width={6}
+                       height={6}
+                       className={className}
+                       fill={destination.bgColor}
+                       stroke={destination.fgColor}
+                       onClick={onClick}
+                       onContextMenu={onContext}
+               >
+                       <title>{description}</title>
+               </rect>;
+       }
+
+       return <circle
+               cx={entrance.pos.x}
+               cy={entrance.pos.y}
+               r="3"
+               className={className}
+               fill={entrance.color}
+               stroke="#000000"
+               onClick={onClick}
+               onContextMenu={onContext}
+       >
+               <title>{entrance.name}</title>
+       </circle>;
+};
+
+MapEntrance.propTypes = {
+       entrance: PropTypes.shape({
+               color: PropTypes.string,
+               id: PropTypes.string,
+               name: PropTypes.string,
+               pos: PropTypes.shape({
+                       x: PropTypes.number,
+                       y: PropTypes.number,
+               })
+       }),
+};
+
+const MapConnector = ({ from, id, isTrash, to, via }) => {
+       const { onConnectorClick } = useTracker();
+
+       const className = React.useMemo(() => {
+               const cs = ['connector'];
+               if (isTrash) cs.push('is-trash');
+               if (via.length) cs.push('is-via');
+               return cs.join(' ');
+       }, [isTrash, via]);
+
+       return <line
+               className={className}
+               onClick={() => onConnectorClick(id)}
+               x1={from.pos.x}
+               y1={from.pos.y}
+               x2={to.pos.x}
+               y2={to.pos.y}
+       />;
+};
+
+MapConnector.propTypes = {
+       from: PropTypes.shape({
+               pos: PropTypes.shape({
+                       x: PropTypes.number,
+                       y: PropTypes.number,
+               })
+       }),
+       id: PropTypes.string,
+       isTrash: PropTypes.bool,
+       to: PropTypes.shape({
+               pos: PropTypes.shape({
+                       x: PropTypes.number,
+                       y: PropTypes.number,
+               })
+       }),
+       via: PropTypes.arrayOf(PropTypes.string),
+};
+
+const MapAnnotation = ({ annotation }) => {
+       return <image
+               className="annotation"
+               href={annotation.icon}
+               x={annotation.pos.x}
+               y={annotation.pos.y}
+               width={annotation.size}
+               height={annotation.size}
+       >
+               <title>{annotation.name}</title>
+       </image>;
+};
+
+MapAnnotation.propTypes = {
+       annotation: PropTypes.shape({
+               icon: PropTypes.string,
+               name: PropTypes.string,
+               pos: PropTypes.shape({
+                       x: PropTypes.number,
+                       y: PropTypes.number,
+               }),
+               size: PropTypes.number,
+       }),
+};
+
+const initPrefs = () => {
+       const dump = localStorage.getItem('zootr.mixed-pools-tracker-prefs');
+       if (dump) {
+               return JSON.parse(dump);
+       }
+       return {
+               showConnectors: true,
+               showEntrances: true,
+               showLabels: true,
+               showMaps: false,
+               showWarps: true,
+       };
+};
+
+const MixedPoolsTracker = () => {
+       const { t } = useTranslation();
+
+       const [connections, setConnections] = React.useState({});
+       const [dragging, setDragging] = React.useState(null);
+       const [prefs, setPrefs] = React.useState(initPrefs());
+       const [trashConnectors, setTrashConnectors] = React.useState([]);
+
+       const setConnection = React.useCallback((src, dst) => {
+               setConnections((c) => {
+                       const newConn = { ...c };
+                       const srcEntrance = getEntrance(src);
+                       const oldTarget = getEntrance(c[src]);
+                       if (oldTarget && (!srcEntrance || !srcEntrance.oneway)) {
+                               // unset old connection
+                               newConn[c[src]] = null;
+                       }
+                       newConn[src] = dst;
+                       if (dst && srcEntrance && !srcEntrance.oneway) {
+                               newConn[dst] = src;
+                       }
+                       return newConn;
+               });
+       }, []);
+
+       const entrances = React.useMemo(() => {
+               const options = [];
+               ROOMS.forEach((room) => {
+                       options.push(room);
+               });
+               AREAS.forEach((area) => {
+                       if (area.entrances) {
+                               area.entrances.forEach((entrance) => {
+                                       if (entrance.oneway) return;
+                                       options.push(getEntranceOfArea(area, entrance.id));
+                               });
+                       } else {
+                               options.push(getEntrance(area.id));
+                       }
+               });
+               return options.map((option) => ({
+                       ...option,
+                       fullName: entranceFull(option),
+               }));
+       }, []);
+
+       const isDragging = React.useCallback((entrance) => {
+               return dragging === entrance.id;
+       }, [dragging]);
+
+       const onMapEntranceClick = React.useCallback((entrance) => {
+               if (dragging) {
+                       if (dragging !== entrance.id) {
+                               setConnection(dragging, entrance.id);
+                       }
+                       setDragging(null);
+               } else {
+                       setDragging(entrance.id);
+               }
+       }, [dragging, setConnection, setDragging]);
+
+       const onConnectorClick = React.useCallback((id) => {
+               setTrashConnectors((tc) => {
+                       if (tc.includes(id)) {
+                               return tc.filter((tid) => tid !== id);
+                       }
+                       return [...tc, id];
+               });
+       }, []);
+
+       const context = React.useMemo(() => ({
+               connections,
+               entrances,
+               isDragging,
+               onConnectorClick,
+               onMapEntranceClick,
+               setConnection,
+       }), [
+               connections,
+               entrances,
+               isDragging,
+               onConnectorClick,
+               onMapEntranceClick,
+               setConnection,
+       ]);
+
+       const superGroups = React.useMemo(() => {
+               const sg = [
+                       {
+                               key: 'one',
+                               groups: ['kf', 'lw', 'sfm', 'gy'],
+                       },
+                       {
+                               key: 'two',
+                               groups: ['kak', 'm1', 'm2', 'hc'],
+                       },
+                       {
+                               key: 'three',
+                               groups: ['hf', 'zr', 'zd', 'zf'],
+                       },
+                       {
+                               key: 'four',
+                               groups: ['lh', 'llr', 'gv', 'gf', 'dcol', 'hw'],
+                       },
+                       {
+                               key: 'five',
+                               groups: ['dmc', 'dmt', 'gc', 'tot'],
+                       },
+               ];
+               sg.forEach((superGroup) => {
+                       superGroup.groups = superGroup.groups.map((areaId) => {
+                               const area = getArea(areaId);
+                               const entranceIds = area.entrances.map((entrance) => `${area.id}.${entrance.id}`);
+                               const checked = entranceIds.filter((entranceId) =>
+                                       Object.entries(connections).find(([a, b]) => b && a === entranceId)
+                               ).length;
+                               return {
+                                       area,
+                                       entranceIds,
+                                       checked,
+                                       total: area.entrances.length,
+                               };
+                       });
+               });
+               return sg;
+       }, [connections]);
+
+       const connectors = React.useMemo(() => {
+               const cs = [];
+               Object.entries(connections).forEach(([from]) => {
+                       const fromEntrance = getEntrance(from);
+                       if (!fromEntrance) return;
+                       const path = resolvePath(connections, from);
+                       if (!path.dst) return;
+                       if (from > path.dst && !fromEntrance.oneway) return;
+                       const fromMap = getMapEntrance(from);
+                       if (!fromMap) return;
+                       const toMap = getMapEntrance(path.dst);
+                       if (!toMap) return;
+                       const id = `${fromMap.id}-${toMap.id}`;
+                       const isTrash = trashConnectors.includes(id);
+                       cs.push({
+                               id,
+                               from: fromMap,
+                               to: toMap,
+                               via: path.via,
+                               isTrash,
+                       });
+               });
+               return cs;
+       }, [connections, trashConnectors]);
+
+       const annotations = React.useMemo(() => {
+               const annotate = [
+                       'songs.minuet',
+                       'songs.bolero',
+                       'songs.serenade',
+                       'songs.nocturne',
+                       'songs.requiem',
+                       'songs.prelude',
+                       'spawns.child',
+                       'spawns.adult',
+                       'owls.lhowl',
+                       'owls.dmtowl',
+               ];
+               const ans = [];
+               annotate.forEach((id) => {
+                       if (!connections[id]) return;
+                       const srcEntrance = getEntrance(id);
+                       if (!srcEntrance) return;
+                       const dstMap = getMapEntrance(connections[id]);
+                       if (!dstMap) return;
+                       ans.push({
+                               icon: srcEntrance.icon,
+                               name: srcEntrance.name,
+                               pos: vecAdd(dstMap.pos, dstMap.annotationOffset || { x: 0, y: 0 }),
+                               size: srcEntrance.iconSize || 8,
+                       });
+               });
+               return ans;
+       }, [connections]);
+
+       const save = React.useCallback(() => {
+               try {
+                       const dump = JSON.stringify({ connections, trashConnectors });
+                       localStorage.setItem('zootr.mixed-pools-tracker-save', dump);
+                       toastr.success(t('general.saveSuccess'));
+               } catch (e) {
+                       toastr.error(t('general.saveError'));
+                       console.error(e);
+               }
+       }, [connections, t, trashConnectors]);
+
+       const load = React.useCallback(() => {
+               try {
+                       const dump = localStorage.getItem('zootr.mixed-pools-tracker-save');
+                       if (!dump) {
+                               toastr.error(t('general.loadError'));
+                               return;
+                       }
+                       const { connections, trashConnectors } = JSON.parse(dump);
+                       if (connections) {
+                               setConnections(connections);
+                       } else {
+                               setConnections({});
+                       }
+                       if (trashConnectors) {
+                               setTrashConnectors(trashConnectors);
+                       } else {
+                               setTrashConnectors([]);
+                       }
+                       toastr.success(t('general.loadSuccess'));
+               } catch (e) {
+                       toastr.error(t('general.loadError'));
+                       console.error(e);
+               }
+       }, [setConnections, t]);
+
+       const reset = React.useCallback(() => {
+               try {
+                       setConnections({});
+                       setTrashConnectors([]);
+                       toastr.success(t('general.resetSuccess'));
+               } catch (e) {
+                       toastr.error(t('general.resetError'));
+               }
+       }, [t]);
+
+       const togglePref = React.useCallback((which) => {
+               setPrefs((oldPrefs) => {
+                       const newPrefs = {
+                               ...oldPrefs,
+                               [which]: !oldPrefs[which],
+                       };
+                       localStorage.setItem('zootr.mixed-pools-tracker-prefs', JSON.stringify(newPrefs));
+                       return newPrefs;
+               });
+       }, []);
+
+       return <CONTEXT.Provider value={context}>
+               <div className="mixed-pools-tracker">
+                       <div className="columns">
+                               {superGroups.map((sg) =>
+                                       <div className="column" key={sg.key}>
+                                               {sg.groups.map(group =>
+                                                       <EntranceGroup
+                                                               checked={group.checked}
+                                                               group={group.area}
+                                                               key={group.area.id}
+                                                               total={group.total}
+                                                       >
+                                                               {group.entranceIds.map((entranceId) =>
+                                                                       <EntranceRow entranceId={entranceId} key={entranceId} />
+                                                               )}
+                                                       </EntranceGroup>
+                                               )}
+                                       </div>
+                               )}
+                       </div>
+                       <div className="columns">
+                               {DUNGEONS.map((area) =>
+                                       <div className="column" key={area.id}>
+                                               <EntranceGroup group={area}>
+                                                       {area.entrances.map((entranceId) =>
+                                                               <EntranceRow
+                                                                       entranceId={entranceId}
+                                                                       key={entranceId}
+                                                               />
+                                                       )}
+                                               </EntranceGroup>
+                                       </div>
+                               )}
+                               <div className="column">
+                                       {AREAS.slice(43, 44).map((area) =>
+                                               <EntranceGroup group={area} key={area.id}>
+                                                       {area.entrances.map((entrance) =>
+                                                               <EntranceRow
+                                                                       entranceId={`${area.id}.${entrance.id}`}
+                                                                       key={entrance.id}
+                                                               />
+                                                       )}
+                                               </EntranceGroup>
+                                       )}
+                               </div>
+                               <div className="column">
+                                       {AREAS.slice(44, 46).map((area) =>
+                                               <EntranceGroup group={area} key={area.id}>
+                                                       {area.entrances.map((entrance) =>
+                                                               <EntranceRow
+                                                                       entranceId={`${area.id}.${entrance.id}`}
+                                                                       key={entrance.id}
+                                                               />
+                                                       )}
+                                               </EntranceGroup>
+                                       )}
+                               </div>
+                       </div>
+                       <div className="map mt-5">
+                               <svg
+                                       viewBox="0 0 500 370"
+                                       onClick={() => { setDragging(null); }}
+                                       onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
+                               >
+                                       <g className="background">
+                                               {MAPS.map((map) =>
+                                                       <g className="area" key={map.id} title={map.name}>
+                                                               <image
+                                                                       href={map.bg.src}
+                                                                       pointerEvents="none"
+                                                                       x={map.bg.pos.x} y={map.bg.pos.y}
+                                                                       width={map.bg.size.x}
+                                                                       style={{ opacity: prefs.showMaps ? 1 : 0.25 }}
+                                                               />
+                                                               {map.labelPos && prefs.showLabels ?
+                                                                       <text
+                                                                               className="area-label"
+                                                                               x={map.labelPos.x}
+                                                                               y={map.labelPos.y}
+                                                                               fill={map.color}
+                                                                       >
+                                                                               {map.short}
+                                                                       </text>
+                                                               : null}
+                                                       </g>
+                                               )}
+                                       </g>
+                                       {prefs.showConnectors ?
+                                               <g title="connectors">
+                                                       {connectors.map((c) =>
+                                                               <MapConnector
+                                                                       key={c.id}
+                                                                       from={c.from}
+                                                                       id={c.id}
+                                                                       isTrash={c.isTrash}
+                                                                       to={c.to}
+                                                                       via={c.via}
+                                                               />
+                                                       )}
+                                               </g>
+                                       : null}
+                                       {MAPS.map((map) =>
+                                               <g className="area" key={map.id} title={map.name}>
+                                                       {prefs.showEntrances ? map.entrances.map((entrance) =>
+                                                               <MapEntrance key={entrance.id} entrance={entrance} />
+                                                       ) : null}
+                                               </g>
+                                       )}
+                                       {prefs.showWarps ?
+                                               <g title="anotations">
+                                                       {annotations.map((a) =>
+                                                               <MapAnnotation
+                                                                       key={`${a.id}`}
+                                                                       annotation={a}
+                                                               />
+                                                       )}
+                                               </g>
+                                       : null}
+                               </svg>
+                       </div>
+                       <div className="menu-bar">
+                               <div className="button-bar">
+                                       <Button
+                                               onClick={() => togglePref('showConnectors')}
+                                               size="sm"
+                                               variant={prefs.showConnectors ? 'secondary' : 'outline-secondary'}
+                                       >
+                                               Connectors
+                                       </Button>
+                                       <Button
+                                               onClick={() => togglePref('showEntrances')}
+                                               size="sm"
+                                               variant={prefs.showEntrances ? 'secondary' : 'outline-secondary'}
+                                       >
+                                               Entrances
+                                       </Button>
+                                       <Button
+                                               onClick={() => togglePref('showLabels')}
+                                               size="sm"
+                                               variant={prefs.showLabels ? 'secondary' : 'outline-secondary'}
+                                       >
+                                               Labels
+                                       </Button>
+                                       <Button
+                                               onClick={() => togglePref('showMaps')}
+                                               size="sm"
+                                               variant={prefs.showMaps ? 'secondary' : 'outline-secondary'}
+                                       >
+                                               Maps
+                                       </Button>
+                                       <Button
+                                               onClick={() => togglePref('showWarps')}
+                                               size="sm"
+                                               variant={prefs.showWarps ? 'secondary' : 'outline-secondary'}
+                                       >
+                                               Warps
+                                       </Button>
+                               </div>
+                       <div className="button-bar">
+                               <Button
+                                       onClick={save}
+                                       size="sm"
+                                       title={t('button.save')}
+                                       variant="outline-secondary"
+                               >
+                                       <Icon.SAVE title="" />
+                               </Button>
+                               <Button
+                                       onClick={load}
+                                       size="sm"
+                                       title={t('button.load')}
+                                       variant="outline-secondary"
+                               >
+                                       <Icon.LOAD title="" />
+                               </Button>
+                               <Button
+                                       onClick={reset}
+                                       size="sm"
+                                       title={t('button.reset')}
+                                       variant="outline-secondary"
+                               >
+                                       <Icon.RESET title="" />
+                               </Button>
+                       </div>
+                       </div>
+               </div>
+       </CONTEXT.Provider>;
+};
+
+export default MixedPoolsTracker;
diff --git a/resources/js/helpers/AlttpBaseRomContext.js b/resources/js/helpers/AlttpBaseRomContext.js
deleted file mode 100644 (file)
index 87f1345..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-import CRC32 from 'crc-32';
-import localforage from 'localforage';
-import PropTypes from 'prop-types';
-import React from 'react';
-import toastr from 'toastr';
-
-import i18n from '../i18n';
-
-const AlttpBaseRomContext = React.createContext(null);
-
-const AlttpBaseRomProvider = ({ children }) => {
-       const [rom, setRom] = React.useState(null);
-
-       const setRomCallback = React.useCallback(buffer => {
-               if (buffer) {
-                       const crc = CRC32.buf(new Uint8Array(buffer));
-                       if (crc === 0x3322EFFC) {
-                               setRom(buffer);
-                               localforage.setItem('alttpBaseRom', buffer);
-                               toastr.success(i18n.t('alttp.baseRomSet'));
-                       } else {
-                               toastr.error(i18n.t('alttp.baseRomInvalid'));
-                       }
-               } else {
-                       setRom(null);
-                       localforage.removeItem('alttpBaseRom');
-                       toastr.success(i18n.t('alttp.baseRomRemoved'));
-               }
-       }, [setRom]);
-
-       React.useEffect(() => {
-               (async () => {
-                       const stored = await localforage.getItem('alttpBaseRom');
-                       if (stored) {
-                               const crc = CRC32.buf(new Uint8Array(stored));
-                               if (crc == 0x3322EFFC) {
-                                       setRom(stored);
-                               }
-                       }
-               })();
-       }, []);
-
-       return <AlttpBaseRomContext.Provider value={{ rom, setRom: setRomCallback }}>
-               {children}
-       </AlttpBaseRomContext.Provider>;
-};
-
-AlttpBaseRomProvider.propTypes = {
-       children: PropTypes.node,
-};
-
-export const useAlttpBaseRom = () => React.useContext(AlttpBaseRomContext);
-
-export default AlttpBaseRomProvider;
diff --git a/resources/js/helpers/AlttpBaseRomContext.jsx b/resources/js/helpers/AlttpBaseRomContext.jsx
new file mode 100644 (file)
index 0000000..87f1345
--- /dev/null
@@ -0,0 +1,54 @@
+import CRC32 from 'crc-32';
+import localforage from 'localforage';
+import PropTypes from 'prop-types';
+import React from 'react';
+import toastr from 'toastr';
+
+import i18n from '../i18n';
+
+const AlttpBaseRomContext = React.createContext(null);
+
+const AlttpBaseRomProvider = ({ children }) => {
+       const [rom, setRom] = React.useState(null);
+
+       const setRomCallback = React.useCallback(buffer => {
+               if (buffer) {
+                       const crc = CRC32.buf(new Uint8Array(buffer));
+                       if (crc === 0x3322EFFC) {
+                               setRom(buffer);
+                               localforage.setItem('alttpBaseRom', buffer);
+                               toastr.success(i18n.t('alttp.baseRomSet'));
+                       } else {
+                               toastr.error(i18n.t('alttp.baseRomInvalid'));
+                       }
+               } else {
+                       setRom(null);
+                       localforage.removeItem('alttpBaseRom');
+                       toastr.success(i18n.t('alttp.baseRomRemoved'));
+               }
+       }, [setRom]);
+
+       React.useEffect(() => {
+               (async () => {
+                       const stored = await localforage.getItem('alttpBaseRom');
+                       if (stored) {
+                               const crc = CRC32.buf(new Uint8Array(stored));
+                               if (crc == 0x3322EFFC) {
+                                       setRom(stored);
+                               }
+                       }
+               })();
+       }, []);
+
+       return <AlttpBaseRomContext.Provider value={{ rom, setRom: setRomCallback }}>
+               {children}
+       </AlttpBaseRomContext.Provider>;
+};
+
+AlttpBaseRomProvider.propTypes = {
+       children: PropTypes.node,
+};
+
+export const useAlttpBaseRom = () => React.useContext(AlttpBaseRomContext);
+
+export default AlttpBaseRomProvider;
diff --git a/resources/js/helpers/Result.js b/resources/js/helpers/Result.js
deleted file mode 100644 (file)
index ed16f38..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-import React from 'react';
-
-import Icon from '../components/common/Icon';
-import { getUserName } from './User';
-
-export const compareUsername = (a, b) => {
-       const a_name = (a && getUserName(a.user)) || '';
-       const b_name = (b && getUserName(b.user)) || '';
-       return a_name.localeCompare(b_name);
-};
-
-export const compareResult = (a, b) => {
-       const a_placement = a && a.placement ? a.placement : 0;
-       const b_placement = b && b.placement ? b.placement : 0;
-       if (a_placement) {
-               if (b_placement) {
-                       if (a_placement < b_placement) return -1;
-                       if (b_placement < a_placement) return 1;
-                       return compareUsername(a, b);
-               }
-               return -1;
-       }
-       if (b_placement) {
-               return 1;
-       }
-       return compareUsername(a, b);
-};
-
-export const formatTime = result => {
-       const hours = `${Math.floor(result.time / 60 / 60)}`;
-       let minutes = `${Math.floor((result.time / 60) % 60)}`;
-       let seconds = `${Math.floor(result.time % 60)}`;
-       while (minutes.length < 2) {
-               minutes = `0${minutes}`;
-       }
-       while (seconds.length < 2) {
-               seconds = `0${seconds}`;
-       }
-       return `${hours}:${minutes}:${seconds}`;
-};
-
-export const getIcon = (result, maySee) => {
-       if (!result || !result.has_finished) {
-               return <Icon.PENDING className="text-muted" size="lg" />;
-       }
-       if (result.forfeit && maySee) {
-               return <Icon.FORFEIT className="text-danger" size="lg" />;
-       }
-       if (result.placement === 1 && maySee) {
-               return <Icon.FIRST_PLACE className="text-gold" size="lg" />;
-       }
-       if (result.placement === 2 && maySee) {
-               return <Icon.SECOND_PLACE className="text-silver" size="lg" />;
-       }
-       if (result.placement === 3 && maySee) {
-               return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
-       }
-       return <Icon.FINISHED className="text-success" size="lg" />;
-};
-
-export const getTime = (result, maySee) => {
-       if (!result || !maySee) {
-               return null;
-       }
-       if (result.time) {
-               return formatTime(result);
-       }
-       if (result.forfeit) {
-               return 'DNF';
-       }
-       return '?';
-};
-
-export const parseTime = str => {
-       if (!str) return null;
-       return `${str}`.trim().split(/[-.: ]+/).reduce((acc,time) => (60 * acc) + +time, 0);
-};
-
-export const sortByTime = (results) => [...results].sort(compareResult);
-
-export const sortByUsername = (results) => [...results].sort(compareUsername);
-
-export default {
-       compareResult,
-       compareUsername,
-       formatTime,
-       getIcon,
-       getTime,
-       parseTime,
-};
diff --git a/resources/js/helpers/Result.jsx b/resources/js/helpers/Result.jsx
new file mode 100644 (file)
index 0000000..ed16f38
--- /dev/null
@@ -0,0 +1,90 @@
+import React from 'react';
+
+import Icon from '../components/common/Icon';
+import { getUserName } from './User';
+
+export const compareUsername = (a, b) => {
+       const a_name = (a && getUserName(a.user)) || '';
+       const b_name = (b && getUserName(b.user)) || '';
+       return a_name.localeCompare(b_name);
+};
+
+export const compareResult = (a, b) => {
+       const a_placement = a && a.placement ? a.placement : 0;
+       const b_placement = b && b.placement ? b.placement : 0;
+       if (a_placement) {
+               if (b_placement) {
+                       if (a_placement < b_placement) return -1;
+                       if (b_placement < a_placement) return 1;
+                       return compareUsername(a, b);
+               }
+               return -1;
+       }
+       if (b_placement) {
+               return 1;
+       }
+       return compareUsername(a, b);
+};
+
+export const formatTime = result => {
+       const hours = `${Math.floor(result.time / 60 / 60)}`;
+       let minutes = `${Math.floor((result.time / 60) % 60)}`;
+       let seconds = `${Math.floor(result.time % 60)}`;
+       while (minutes.length < 2) {
+               minutes = `0${minutes}`;
+       }
+       while (seconds.length < 2) {
+               seconds = `0${seconds}`;
+       }
+       return `${hours}:${minutes}:${seconds}`;
+};
+
+export const getIcon = (result, maySee) => {
+       if (!result || !result.has_finished) {
+               return <Icon.PENDING className="text-muted" size="lg" />;
+       }
+       if (result.forfeit && maySee) {
+               return <Icon.FORFEIT className="text-danger" size="lg" />;
+       }
+       if (result.placement === 1 && maySee) {
+               return <Icon.FIRST_PLACE className="text-gold" size="lg" />;
+       }
+       if (result.placement === 2 && maySee) {
+               return <Icon.SECOND_PLACE className="text-silver" size="lg" />;
+       }
+       if (result.placement === 3 && maySee) {
+               return <Icon.THIRD_PLACE className="text-bronze" size="lg" />;
+       }
+       return <Icon.FINISHED className="text-success" size="lg" />;
+};
+
+export const getTime = (result, maySee) => {
+       if (!result || !maySee) {
+               return null;
+       }
+       if (result.time) {
+               return formatTime(result);
+       }
+       if (result.forfeit) {
+               return 'DNF';
+       }
+       return '?';
+};
+
+export const parseTime = str => {
+       if (!str) return null;
+       return `${str}`.trim().split(/[-.: ]+/).reduce((acc,time) => (60 * acc) + +time, 0);
+};
+
+export const sortByTime = (results) => [...results].sort(compareResult);
+
+export const sortByUsername = (results) => [...results].sort(compareUsername);
+
+export default {
+       compareResult,
+       compareUsername,
+       formatTime,
+       getIcon,
+       getTime,
+       parseTime,
+};
diff --git a/resources/js/helpers/nl2br.js b/resources/js/helpers/nl2br.js
deleted file mode 100644 (file)
index 60d695a..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-
-const nl2br = str => {
-       if (typeof str !== 'string') {
-               return str;
-       }
-       const nl = /(\r\n|\r|\n)/g;
-       return str.split(nl).map((line, index) => {
-               if (line.match(nl)) {
-                       return <br key={index} />;
-               }
-               return line;
-       });
-};
-
-export default nl2br;
diff --git a/resources/js/helpers/nl2br.jsx b/resources/js/helpers/nl2br.jsx
new file mode 100644 (file)
index 0000000..60d695a
--- /dev/null
@@ -0,0 +1,16 @@
+import React from 'react';
+
+const nl2br = str => {
+       if (typeof str !== 'string') {
+               return str;
+       }
+       const nl = /(\r\n|\r|\n)/g;
+       return str.split(nl).map((line, index) => {
+               if (line.match(nl)) {
+                       return <br key={index} />;
+               }
+               return line;
+       });
+};
+
+export default nl2br;
diff --git a/resources/js/hooks/snes.js b/resources/js/hooks/snes.js
deleted file mode 100644 (file)
index 344c1eb..0000000
+++ /dev/null
@@ -1,151 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import SettingsDialog from '../components/snes/SettingsDialog';
-import SNESSocket from '../helpers/SNESSocket';
-
-const context = React.createContext({});
-
-export const useSNES = () => React.useContext(context);
-
-export const SNESProvider = ({ children }) => {
-       const [enabled, setEnabled] = React.useState(false);
-       const [showSettingsDialog, setShowSettingsDialog] = React.useState(false);
-
-       const sock = React.useRef(null);
-
-       const [settings, setSettings] = React.useState({
-               proto: 'ws',
-               host: 'localhost',
-               port: 23074,
-               device: '',
-       });
-
-       const [status, setStatus] = React.useState({
-               connected: false,
-               device: '',
-               deviceList: [],
-               error: false,
-       });
-
-       React.useEffect(() => {
-               if (sock.current) {
-                       sock.current.close();
-                       sock.current = null;
-               }
-               if (enabled) {
-                       const tryAttach = () => {
-                               const { deviceList } = sock.current;
-                               let device = '';
-                               if (deviceList.includes(settings.device)) {
-                                       device = settings.device;
-                               } else if (deviceList.length > 0) {
-                                       device = deviceList[0];
-                               }
-                               setStatus(s => ({ ...s, device, deviceList }));
-                               if (device) {
-                                       sock.current.attachDevice(device);
-                               }
-                       };
-                       sock.current = new SNESSocket(`${settings.proto}://${settings.host}:${settings.port}`);
-                       sock.current.onclose = () => {
-                               setStatus({
-                                       connected: false,
-                                       device: '',
-                                       deviceList: [],
-                                       error: false,
-                               });
-                       };
-                       sock.current.onerror = (e) => {
-                               setStatus({
-                                       connected: false,
-                                       device: '',
-                                       deviceList: [],
-                                       error: e,
-                               });
-                       };
-                       sock.current.onopen = () => {
-                               setStatus({
-                                       connected: true,
-                                       device: '',
-                                       deviceList: [],
-                                       error: false,
-                               });
-                               sock.current.requestDeviceList(() => {
-                                       tryAttach();
-                               });
-                       };
-                       const watchdog = setInterval(() => {
-                               if (!sock.current.isOpen()) {
-                                       sock.current.open();
-                                       return;
-                               }
-                               if (!sock.current.device) {
-                                       sock.current.requestDeviceList(() => {
-                                               tryAttach();
-                                       });
-                               }
-                       }, 5000);
-                       return () => {
-                               clearInterval(watchdog);
-                       };
-               }
-       }, [enabled, settings]);
-
-       const enable = React.useCallback(() => {
-               setEnabled(prevEnabled => {
-                       if (prevEnabled) return true;
-                       return true;
-               });
-       }, []);
-
-       const disable = React.useCallback(() => {
-               setEnabled(prevEnabled => {
-                       if (!prevEnabled) return false;
-                       return false;
-               });
-       }, []);
-
-       const openSettings = React.useCallback(() => {
-               setShowSettingsDialog(true);
-       }, []);
-
-       const closeSettings = React.useCallback(() => {
-               setShowSettingsDialog(false);
-       }, []);
-
-       const saveSettings = React.useCallback((values) => {
-               setSettings(s => {
-                       const newSettings = { ...s, ...values };
-                       localStorage.setItem('snes.settings', JSON.stringify(newSettings));
-                       return newSettings;
-               });
-               setShowSettingsDialog(false);
-       }, []);
-
-       React.useEffect(() => {
-               const savedSettings = localStorage.getItem('snes.settings');
-               if (savedSettings) {
-                       setSettings(JSON.parse(savedSettings));
-               }
-       }, []);
-
-       const value = React.useMemo(() => {
-               return { disable, enable, enabled, openSettings, settings, sock, status };
-       }, [disable, enable, enabled, openSettings, settings, sock, status]);
-
-       return <context.Provider value={value}>
-               {children}
-               <SettingsDialog
-                       deviceList={status.deviceList}
-                       onHide={closeSettings}
-                       onSubmit={saveSettings}
-                       settings={settings}
-                       show={showSettingsDialog}
-               />
-       </context.Provider>;
-};
-
-SNESProvider.propTypes = {
-       children: PropTypes.node,
-};
diff --git a/resources/js/hooks/snes.jsx b/resources/js/hooks/snes.jsx
new file mode 100644 (file)
index 0000000..344c1eb
--- /dev/null
@@ -0,0 +1,151 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import SettingsDialog from '../components/snes/SettingsDialog';
+import SNESSocket from '../helpers/SNESSocket';
+
+const context = React.createContext({});
+
+export const useSNES = () => React.useContext(context);
+
+export const SNESProvider = ({ children }) => {
+       const [enabled, setEnabled] = React.useState(false);
+       const [showSettingsDialog, setShowSettingsDialog] = React.useState(false);
+
+       const sock = React.useRef(null);
+
+       const [settings, setSettings] = React.useState({
+               proto: 'ws',
+               host: 'localhost',
+               port: 23074,
+               device: '',
+       });
+
+       const [status, setStatus] = React.useState({
+               connected: false,
+               device: '',
+               deviceList: [],
+               error: false,
+       });
+
+       React.useEffect(() => {
+               if (sock.current) {
+                       sock.current.close();
+                       sock.current = null;
+               }
+               if (enabled) {
+                       const tryAttach = () => {
+                               const { deviceList } = sock.current;
+                               let device = '';
+                               if (deviceList.includes(settings.device)) {
+                                       device = settings.device;
+                               } else if (deviceList.length > 0) {
+                                       device = deviceList[0];
+                               }
+                               setStatus(s => ({ ...s, device, deviceList }));
+                               if (device) {
+                                       sock.current.attachDevice(device);
+                               }
+                       };
+                       sock.current = new SNESSocket(`${settings.proto}://${settings.host}:${settings.port}`);
+                       sock.current.onclose = () => {
+                               setStatus({
+                                       connected: false,
+                                       device: '',
+                                       deviceList: [],
+                                       error: false,
+                               });
+                       };
+                       sock.current.onerror = (e) => {
+                               setStatus({
+                                       connected: false,
+                                       device: '',
+                                       deviceList: [],
+                                       error: e,
+                               });
+                       };
+                       sock.current.onopen = () => {
+                               setStatus({
+                                       connected: true,
+                                       device: '',
+                                       deviceList: [],
+                                       error: false,
+                               });
+                               sock.current.requestDeviceList(() => {
+                                       tryAttach();
+                               });
+                       };
+                       const watchdog = setInterval(() => {
+                               if (!sock.current.isOpen()) {
+                                       sock.current.open();
+                                       return;
+                               }
+                               if (!sock.current.device) {
+                                       sock.current.requestDeviceList(() => {
+                                               tryAttach();
+                                       });
+                               }
+                       }, 5000);
+                       return () => {
+                               clearInterval(watchdog);
+                       };
+               }
+       }, [enabled, settings]);
+
+       const enable = React.useCallback(() => {
+               setEnabled(prevEnabled => {
+                       if (prevEnabled) return true;
+                       return true;
+               });
+       }, []);
+
+       const disable = React.useCallback(() => {
+               setEnabled(prevEnabled => {
+                       if (!prevEnabled) return false;
+                       return false;
+               });
+       }, []);
+
+       const openSettings = React.useCallback(() => {
+               setShowSettingsDialog(true);
+       }, []);
+
+       const closeSettings = React.useCallback(() => {
+               setShowSettingsDialog(false);
+       }, []);
+
+       const saveSettings = React.useCallback((values) => {
+               setSettings(s => {
+                       const newSettings = { ...s, ...values };
+                       localStorage.setItem('snes.settings', JSON.stringify(newSettings));
+                       return newSettings;
+               });
+               setShowSettingsDialog(false);
+       }, []);
+
+       React.useEffect(() => {
+               const savedSettings = localStorage.getItem('snes.settings');
+               if (savedSettings) {
+                       setSettings(JSON.parse(savedSettings));
+               }
+       }, []);
+
+       const value = React.useMemo(() => {
+               return { disable, enable, enabled, openSettings, settings, sock, status };
+       }, [disable, enable, enabled, openSettings, settings, sock, status]);
+
+       return <context.Provider value={value}>
+               {children}
+               <SettingsDialog
+                       deviceList={status.deviceList}
+                       onHide={closeSettings}
+                       onSubmit={saveSettings}
+                       settings={settings}
+                       show={showSettingsDialog}
+               />
+       </context.Provider>;
+};
+
+SNESProvider.propTypes = {
+       children: PropTypes.node,
+};
diff --git a/resources/js/hooks/tracker.js b/resources/js/hooks/tracker.js
deleted file mode 100644 (file)
index d0fb6b8..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-
-import {
-       CONFIG,
-       DUNGEONS,
-       applyLogic,
-       configureDungeons,
-       makeEmptyState,
-       mergeStates,
-} from '../helpers/tracker';
-
-const context = React.createContext({});
-
-export const useTracker = () => React.useContext(context);
-
-export const TrackerProvider = ({ children }) => {
-       const [config, setConfig] = React.useState(CONFIG);
-       const [state, setState] = React.useState(makeEmptyState());
-       const [autoState, setAutoState] = React.useState(makeEmptyState());
-       const [manualState, setManualState] = React.useState(makeEmptyState());
-       const [dungeons, setDungeons] = React.useState(DUNGEONS);
-       const [logic, setLogic] = React.useState({});
-       const [pins, setPins] = React.useState([]);
-
-       const saveConfig = React.useCallback((values) => {
-               setConfig(s => {
-                       const newConfig = { ...s, ...values };
-                       localStorage.setItem('tracker.config', JSON.stringify(newConfig));
-                       return newConfig;
-               });
-       }, []);
-
-       const addPin = React.useCallback((pin) => {
-               setPins(ps => {
-                       const id = ps.length ? ps[ps.length - 1].id + 1 : 1;
-                       return [...ps, { ...pin, id }];
-               });
-       }, []);
-
-       const removePin = React.useCallback((pin) => {
-               setPins(ps => ps.filter(p => p.id !== pin.id));
-       }, []);
-
-       React.useEffect(() => {
-               const savedConfig = localStorage.getItem('tracker.config');
-               if (savedConfig) {
-                       setConfig(c => ({ ...c, ...JSON.parse(savedConfig) }));
-               }
-       }, []);
-
-       React.useEffect(() => {
-               const newDungeons = configureDungeons(config);
-               setDungeons(newDungeons);
-       }, [config]);
-
-       React.useEffect(() => {
-               setState(mergeStates(autoState, manualState));
-       }, [autoState, manualState]);
-
-       React.useEffect(() => {
-               setLogic(applyLogic(config, dungeons, state));
-       }, [config, dungeons, state]);
-
-       const value = React.useMemo(() => {
-               return {
-                       addPin,
-                       config,
-                       dungeons,
-                       logic,
-                       pins,
-                       removePin,
-                       saveConfig,
-                       setAutoState,
-                       setManualState,
-                       state,
-               };
-       }, [config, dungeons, logic, pins, state]);
-
-       return <context.Provider value={value}>
-               {children}
-       </context.Provider>;
-};
-
-TrackerProvider.propTypes = {
-       children: PropTypes.node,
-};
diff --git a/resources/js/hooks/tracker.jsx b/resources/js/hooks/tracker.jsx
new file mode 100644 (file)
index 0000000..d0fb6b8
--- /dev/null
@@ -0,0 +1,87 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {
+       CONFIG,
+       DUNGEONS,
+       applyLogic,
+       configureDungeons,
+       makeEmptyState,
+       mergeStates,
+} from '../helpers/tracker';
+
+const context = React.createContext({});
+
+export const useTracker = () => React.useContext(context);
+
+export const TrackerProvider = ({ children }) => {
+       const [config, setConfig] = React.useState(CONFIG);
+       const [state, setState] = React.useState(makeEmptyState());
+       const [autoState, setAutoState] = React.useState(makeEmptyState());
+       const [manualState, setManualState] = React.useState(makeEmptyState());
+       const [dungeons, setDungeons] = React.useState(DUNGEONS);
+       const [logic, setLogic] = React.useState({});
+       const [pins, setPins] = React.useState([]);
+
+       const saveConfig = React.useCallback((values) => {
+               setConfig(s => {
+                       const newConfig = { ...s, ...values };
+                       localStorage.setItem('tracker.config', JSON.stringify(newConfig));
+                       return newConfig;
+               });
+       }, []);
+
+       const addPin = React.useCallback((pin) => {
+               setPins(ps => {
+                       const id = ps.length ? ps[ps.length - 1].id + 1 : 1;
+                       return [...ps, { ...pin, id }];
+               });
+       }, []);
+
+       const removePin = React.useCallback((pin) => {
+               setPins(ps => ps.filter(p => p.id !== pin.id));
+       }, []);
+
+       React.useEffect(() => {
+               const savedConfig = localStorage.getItem('tracker.config');
+               if (savedConfig) {
+                       setConfig(c => ({ ...c, ...JSON.parse(savedConfig) }));
+               }
+       }, []);
+
+       React.useEffect(() => {
+               const newDungeons = configureDungeons(config);
+               setDungeons(newDungeons);
+       }, [config]);
+
+       React.useEffect(() => {
+               setState(mergeStates(autoState, manualState));
+       }, [autoState, manualState]);
+
+       React.useEffect(() => {
+               setLogic(applyLogic(config, dungeons, state));
+       }, [config, dungeons, state]);
+
+       const value = React.useMemo(() => {
+               return {
+                       addPin,
+                       config,
+                       dungeons,
+                       logic,
+                       pins,
+                       removePin,
+                       saveConfig,
+                       setAutoState,
+                       setManualState,
+                       state,
+               };
+       }, [config, dungeons, logic, pins, state]);
+
+       return <context.Provider value={value}>
+               {children}
+       </context.Provider>;
+};
+
+TrackerProvider.propTypes = {
+       children: PropTypes.node,
+};
diff --git a/resources/js/hooks/user.js b/resources/js/hooks/user.js
deleted file mode 100644 (file)
index 03173d5..0000000
+++ /dev/null
@@ -1,78 +0,0 @@
-import axios from 'axios';
-import { isEqual } from 'lodash';
-import PropTypes from 'prop-types';
-import React from 'react';
-
-const context = React.createContext({
-       login: () => false,
-       logout: () => false,
-       user: null,
-});
-
-export const useUser = () => React.useContext(context);
-
-export const withUser = (WrappedComponent, as) => function WithUserContext(props) {
-       return <context.Consumer>
-               {ctx => <WrappedComponent {...{[as || 'user']: ctx.user, ...props}} />}
-       </context.Consumer>;
-};
-
-export const UserProvider = ({ children }) => {
-       const [user, setUser] = React.useState(null);
-
-       const fetchUser = React.useCallback(async () => {
-               try {
-                       const response = await axios.get('/api/user');
-                       setUser(user => isEqual(user, response.data) ? user : response.data);
-               } catch (e) {
-                       setUser(null);
-               }
-       }, []);
-
-       React.useEffect(() => {
-               let timer = null;
-               axios
-                       .get('/sanctum/csrf-cookie')
-                       .then(() => {
-                               fetchUser();
-                               timer = setInterval(fetchUser, 5 * 60 * 1000);
-                       });
-               return () => {
-                       if (timer) clearInterval(timer);
-               };
-       }, []);
-
-       const login = React.useCallback(async (creds) => {
-               try {
-                       await axios.post('/login', {
-                               ...creds,
-                               remember: 'on',
-                       });
-                       await fetchUser();
-               } catch (error) {
-                       if (error.response && error.response.status === 419) {
-                               await axios.get('/sanctum/csrf-cookie');
-                               await axios.post('/login', {
-                                       ...creds,
-                                       remember: 'on',
-                               });
-                               await fetchUser();
-                       } else {
-                               throw error;
-                       }
-               }
-       }, []);
-
-       const logout = React.useCallback(async () => {
-               await axios.post('/logout');
-               setUser(null);
-       }, []);
-
-       return <context.Provider value={{ login, logout, user }}>
-               {children}
-       </context.Provider>;
-};
-
-UserProvider.propTypes = {
-       children: PropTypes.node,
-};
diff --git a/resources/js/hooks/user.jsx b/resources/js/hooks/user.jsx
new file mode 100644 (file)
index 0000000..03173d5
--- /dev/null
@@ -0,0 +1,78 @@
+import axios from 'axios';
+import { isEqual } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const context = React.createContext({
+       login: () => false,
+       logout: () => false,
+       user: null,
+});
+
+export const useUser = () => React.useContext(context);
+
+export const withUser = (WrappedComponent, as) => function WithUserContext(props) {
+       return <context.Consumer>
+               {ctx => <WrappedComponent {...{[as || 'user']: ctx.user, ...props}} />}
+       </context.Consumer>;
+};
+
+export const UserProvider = ({ children }) => {
+       const [user, setUser] = React.useState(null);
+
+       const fetchUser = React.useCallback(async () => {
+               try {
+                       const response = await axios.get('/api/user');
+                       setUser(user => isEqual(user, response.data) ? user : response.data);
+               } catch (e) {
+                       setUser(null);
+               }
+       }, []);
+
+       React.useEffect(() => {
+               let timer = null;
+               axios
+                       .get('/sanctum/csrf-cookie')
+                       .then(() => {
+                               fetchUser();
+                               timer = setInterval(fetchUser, 5 * 60 * 1000);
+                       });
+               return () => {
+                       if (timer) clearInterval(timer);
+               };
+       }, []);
+
+       const login = React.useCallback(async (creds) => {
+               try {
+                       await axios.post('/login', {
+                               ...creds,
+                               remember: 'on',
+                       });
+                       await fetchUser();
+               } catch (error) {
+                       if (error.response && error.response.status === 419) {
+                               await axios.get('/sanctum/csrf-cookie');
+                               await axios.post('/login', {
+                                       ...creds,
+                                       remember: 'on',
+                               });
+                               await fetchUser();
+                       } else {
+                               throw error;
+                       }
+               }
+       }, []);
+
+       const logout = React.useCallback(async () => {
+               await axios.post('/logout');
+               setUser(null);
+       }, []);
+
+       return <context.Provider value={{ login, logout, user }}>
+               {children}
+       </context.Provider>;
+};
+
+UserProvider.propTypes = {
+       children: PropTypes.node,
+};
diff --git a/resources/js/index.js b/resources/js/index.js
deleted file mode 100644 (file)
index f4cda61..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * First we will load all of this project's JavaScript dependencies which
- * includes React and other helpers. It's a great starting point while
- * building robust, powerful web applications using React + Laravel.
- */
-
-import './bootstrap';
-
-import React from 'react';
-import { createRoot } from 'react-dom/client';
-
-import toastr from 'toastr';
-toastr.options.positionClass = 'toast-bottom-right';
-
-/**
- * Next, we will create a fresh React component instance and attach it to
- * the page. Then, you may begin adding components to this application
- * or customize the JavaScript scaffolding to fit your unique needs.
- */
-
-import App from './app';
-
-if (document.getElementById('react-root')) {
-       const root = createRoot(document.getElementById('react-root'));
-       root.render(<App />);
-}
diff --git a/resources/js/index.jsx b/resources/js/index.jsx
new file mode 100644 (file)
index 0000000..f44ece7
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * First we will load all of this project's JavaScript dependencies which
+ * includes React and other helpers. It's a great starting point while
+ * building robust, powerful web applications using React + Laravel.
+ */
+
+import './bootstrap';
+import '../sass/app.scss';
+
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+
+import toastr from 'toastr';
+toastr.options.positionClass = 'toast-bottom-right';
+
+/**
+ * Next, we will create a fresh React component instance and attach it to
+ * the page. Then, you may begin adding components to this application
+ * or customize the JavaScript scaffolding to fit your unique needs.
+ */
+
+import App from './app';
+
+if (document.getElementById('react-root')) {
+       const root = createRoot(document.getElementById('react-root'));
+       root.render(<App />);
+}
diff --git a/resources/js/pages/AlttpSeed.js b/resources/js/pages/AlttpSeed.js
deleted file mode 100644 (file)
index 74769d8..0000000
+++ /dev/null
@@ -1,106 +0,0 @@
-import axios from 'axios';
-import React, { useCallback, useEffect, useState } from 'react';
-import { Helmet } from 'react-helmet';
-import { useParams } from 'react-router-dom';
-
-import NotFound from './NotFound';
-import Seed from '../components/alttp-seeds/Seed';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import ErrorMessage from '../components/common/ErrorMessage';
-import Loading from '../components/common/Loading';
-
-export const Component = () => {
-       const params = useParams();
-       const { hash } = params;
-
-       const [error, setError] = useState(null);
-       const [loading, setLoading] = useState(true);
-       const [patch, setPatch] = useState(null);
-       const [seed, setSeed] = useState(null);
-
-       const loadSeed = useCallback((hash, ctrl) => {
-               axios
-                       .get(`/api/alttp-seed/${hash}`, { signal: ctrl.signal })
-                       .then(response => {
-                               setError(null);
-                               setLoading(false);
-                               setSeed(response.data);
-                       })
-                       .catch(error => {
-                               setError(error);
-                               setLoading(false);
-                               setSeed(null);
-                       });
-       }, []);
-
-       useEffect(() => {
-               setLoading(true);
-               const ctrl = new AbortController();
-               loadSeed(hash, ctrl);
-               return () => {
-                       ctrl.abort();
-               };
-       }, [hash]);
-
-       useEffect(() => {
-               if (!seed || seed.status !== 'pending') {
-                       return;
-               }
-               const ctrl = new AbortController();
-               const timer = setTimeout(() => {
-                       loadSeed(seed.hash, ctrl);
-               }, 2000);
-               return () => {
-                       clearTimeout(timer);
-                       ctrl.abort();
-               };
-       }, [seed]);
-
-       useEffect(() => {
-               setPatch(null);
-               if (!seed || seed.status !== 'generated') {
-                       return;
-               }
-               const ctrl = new AbortController();
-               axios
-                       .get(`/alttp-seeds/${hash}.bps`, {
-                               responseType: 'arraybuffer',
-                               signal: ctrl.signal,
-                       })
-                       .then(response => {
-                               setPatch(response.data);
-                       })
-                       .catch(error => {
-                               setError(error);
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [hash, seed]);
-
-       const retry = useCallback(async () => {
-               await axios.post(`/api/alttp-seed/${hash}/retry`);
-               setSeed(seed => ({ ...seed, status: 'pending' }));
-       });
-
-       if (loading) {
-               return <Loading />;
-       }
-
-       if (error) {
-               return <ErrorMessage error={error} />;
-       }
-
-       if (!seed) {
-               return <NotFound />;
-       }
-
-       return <ErrorBoundary>
-               <Helmet>
-                       {seed ?
-                               <title>{seed.hash}</title>
-                       : null}
-               </Helmet>
-               <Seed onRetry={retry} patch={patch} seed={seed} />
-       </ErrorBoundary>;
-};
diff --git a/resources/js/pages/AlttpSeed.jsx b/resources/js/pages/AlttpSeed.jsx
new file mode 100644 (file)
index 0000000..74769d8
--- /dev/null
@@ -0,0 +1,106 @@
+import axios from 'axios';
+import React, { useCallback, useEffect, useState } from 'react';
+import { Helmet } from 'react-helmet';
+import { useParams } from 'react-router-dom';
+
+import NotFound from './NotFound';
+import Seed from '../components/alttp-seeds/Seed';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Loading from '../components/common/Loading';
+
+export const Component = () => {
+       const params = useParams();
+       const { hash } = params;
+
+       const [error, setError] = useState(null);
+       const [loading, setLoading] = useState(true);
+       const [patch, setPatch] = useState(null);
+       const [seed, setSeed] = useState(null);
+
+       const loadSeed = useCallback((hash, ctrl) => {
+               axios
+                       .get(`/api/alttp-seed/${hash}`, { signal: ctrl.signal })
+                       .then(response => {
+                               setError(null);
+                               setLoading(false);
+                               setSeed(response.data);
+                       })
+                       .catch(error => {
+                               setError(error);
+                               setLoading(false);
+                               setSeed(null);
+                       });
+       }, []);
+
+       useEffect(() => {
+               setLoading(true);
+               const ctrl = new AbortController();
+               loadSeed(hash, ctrl);
+               return () => {
+                       ctrl.abort();
+               };
+       }, [hash]);
+
+       useEffect(() => {
+               if (!seed || seed.status !== 'pending') {
+                       return;
+               }
+               const ctrl = new AbortController();
+               const timer = setTimeout(() => {
+                       loadSeed(seed.hash, ctrl);
+               }, 2000);
+               return () => {
+                       clearTimeout(timer);
+                       ctrl.abort();
+               };
+       }, [seed]);
+
+       useEffect(() => {
+               setPatch(null);
+               if (!seed || seed.status !== 'generated') {
+                       return;
+               }
+               const ctrl = new AbortController();
+               axios
+                       .get(`/alttp-seeds/${hash}.bps`, {
+                               responseType: 'arraybuffer',
+                               signal: ctrl.signal,
+                       })
+                       .then(response => {
+                               setPatch(response.data);
+                       })
+                       .catch(error => {
+                               setError(error);
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [hash, seed]);
+
+       const retry = useCallback(async () => {
+               await axios.post(`/api/alttp-seed/${hash}/retry`);
+               setSeed(seed => ({ ...seed, status: 'pending' }));
+       });
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       if (!seed) {
+               return <NotFound />;
+       }
+
+       return <ErrorBoundary>
+               <Helmet>
+                       {seed ?
+                               <title>{seed.hash}</title>
+                       : null}
+               </Helmet>
+               <Seed onRetry={retry} patch={patch} seed={seed} />
+       </ErrorBoundary>;
+};
diff --git a/resources/js/pages/DiscordBot.js b/resources/js/pages/DiscordBot.js
deleted file mode 100644 (file)
index 0e1d2d1..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import { Button, Container } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-
-import Icon from '../components/common/Icon';
-import Controls from '../components/discord-bot/Controls';
-
-const authEndpoint = 'https://discord.com/oauth2/authorize';
-const clientId = process.env.MIX_DISCORD_CLIENT_ID;
-
-export const Component = () => {
-       const { t } = useTranslation();
-
-       return <Container>
-               <h1>{t('discordBot.heading')}</h1>
-               <Helmet>
-                       <title>{t('discordBot.heading')}</title>
-               </Helmet>
-               <p>
-                       <span className="button-bar">
-                               <Button
-                                       href={`${authEndpoint}?client_id=${clientId}&scope=bot%20applications.commands`}
-                                       target="_blank"
-                                       variant="discord"
-                               >
-                                       <Icon.DISCORD title="" />
-                                       {' '}
-                                       {t('discordBot.invite')}
-                               </Button>
-                       </span>
-               </p>
-               <h2>{t('discordBot.controls')}</h2>
-               <Controls />
-       </Container>;
-};
diff --git a/resources/js/pages/DiscordBot.jsx b/resources/js/pages/DiscordBot.jsx
new file mode 100644 (file)
index 0000000..111cbbe
--- /dev/null
@@ -0,0 +1,36 @@
+import React from 'react';
+import { Button, Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../components/common/Icon';
+import Controls from '../components/discord-bot/Controls';
+
+const authEndpoint = 'https://discord.com/oauth2/authorize';
+const clientId = import.meta.env.VITE_DISCORD_CLIENT_ID;
+
+export const Component = () => {
+       const { t } = useTranslation();
+
+       return <Container>
+               <h1>{t('discordBot.heading')}</h1>
+               <Helmet>
+                       <title>{t('discordBot.heading')}</title>
+               </Helmet>
+               <p>
+                       <span className="button-bar">
+                               <Button
+                                       href={`${authEndpoint}?client_id=${clientId}&scope=bot%20applications.commands`}
+                                       target="_blank"
+                                       variant="discord"
+                               >
+                                       <Icon.DISCORD title="" />
+                                       {' '}
+                                       {t('discordBot.invite')}
+                               </Button>
+                       </span>
+               </p>
+               <h2>{t('discordBot.controls')}</h2>
+               <Controls />
+       </Container>;
+};
diff --git a/resources/js/pages/DoorsTracker.js b/resources/js/pages/DoorsTracker.js
deleted file mode 100644 (file)
index 087a249..0000000
+++ /dev/null
@@ -1,186 +0,0 @@
-import React from 'react';
-import { Helmet } from 'react-helmet';
-
-import ZeldaIcon from '../components/common/ZeldaIcon';
-
-const DUNGEONS = [
-       'hc',
-       'ct',
-       'ep',
-       'dp',
-       'th',
-       'pd',
-       'sp',
-       'sw',
-       'tt',
-       'ip',
-       'mm',
-       'tr',
-       'gt',
-];
-
-const ITEMS = [
-       'compass',
-       'map',
-       'big-key',
-       'bow',
-       'hookshot',
-       'fire-rod',
-       'lamp',
-       'hammer',
-       'somaria',
-       'fighter-sword',
-       'boots',
-       'glove',
-       'flippers',
-];
-
-const ITEM_CLASSES = {
-       'compass': 'dungeon-item',
-       'map': 'dungeon-item',
-       'big-key': 'dungeon-item',
-       'bow': 'item',
-       'hookshot': 'item',
-       'fire-rod': 'item',
-       'lamp': 'item',
-       'hammer': 'item',
-       'somaria': 'item',
-       'fighter-sword': 'item',
-       'boots': 'item',
-       'glove': 'item',
-       'flippers': 'item',
-};
-
-const nextCSwitch = cur => {
-       switch (cur) {
-               case 'blue':
-                       return 'red';
-               case 'red':
-                       return '';
-               default:
-                       return 'blue';
-       }
-};
-
-const prevCSwitch = cur => nextCSwitch(nextCSwitch(cur));
-
-export const Component = () => {
-       const [state, setState] = React.useState(DUNGEONS.reduce((state, dungeon) => ({
-               ...state,
-               [dungeon]: ITEMS.reduce((items, item) => ({
-                       ...items,
-                       [item]: false,
-               }), {
-                       boss: true,
-                       cswitch: '',
-                       keys: 1,
-               }),
-       }), {}));
-
-       const handleItemClick = React.useCallback((dungeon, item) => e => {
-               setState(state => ({
-                       ...state,
-                       [dungeon]: {
-                               ...state[dungeon],
-                               [item]: !state[dungeon][item],
-                       },
-               }));
-               e.preventDefault();
-               e.stopPropagation();
-       });
-
-       const handleCSwitchClick = React.useCallback(dungeon => e => {
-               setState(state => ({
-                       ...state,
-                       [dungeon]: {
-                               ...state[dungeon],
-                               cswitch: nextCSwitch(state[dungeon].cswitch),
-                       },
-               }));
-               e.preventDefault();
-               e.stopPropagation();
-       });
-
-       const handleCSwitchRightClick = React.useCallback(dungeon => e => {
-               setState(state => ({
-                       ...state,
-                       [dungeon]: {
-                               ...state[dungeon],
-                               cswitch: prevCSwitch(state[dungeon].cswitch),
-                       },
-               }));
-               e.preventDefault();
-               e.stopPropagation();
-       });
-
-       const handleKeysClick = React.useCallback(dungeon => e => {
-               setState(state => ({
-                       ...state,
-                       [dungeon]: {
-                               ...state[dungeon],
-                               keys: state[dungeon].keys + 1,
-                       },
-               }));
-               e.preventDefault();
-               e.stopPropagation();
-       });
-
-       const handleKeysRightClick = React.useCallback(dungeon => e => {
-               setState(state => ({
-                       ...state,
-                       [dungeon]: {
-                               ...state[dungeon],
-                               keys: Math.max(state[dungeon].keys - 1, 0),
-                       },
-               }));
-               e.preventDefault();
-               e.stopPropagation();
-       });
-
-       return <>
-               <Helmet>
-                       <title>Doors Tracker</title>
-                       <meta name="description" content="Doors Tracker" />
-               </Helmet>
-               <div className="doors-tracker d-flex flex-column">
-                       {DUNGEONS.map(dungeon =>
-                               <div className="d-flex flex-row" key={dungeon}>
-                                       <div
-                                               className={`cell ${state[dungeon].boss ? 'on' : 'off'} dungeon`}
-                                               onClick={handleItemClick(dungeon, 'boss')}
-                                       >
-                                               <ZeldaIcon name={`dungeon-${dungeon}`} />
-                                       </div>
-                                       <div
-                                               className={`cell ${state[dungeon].keys ? 'on' : 'off'} keys`}
-                                               onClick={handleKeysClick(dungeon)}
-                                               onContextMenu={handleKeysRightClick(dungeon)}
-                                       >
-                                               {state[dungeon].keys}
-                                       </div>
-                                       <div
-                                               className={`cell ${state[dungeon].cswitch ? 'on' : 'off'} cswitch`}
-                                               onClick={handleCSwitchClick(dungeon)}
-                                               onContextMenu={handleCSwitchRightClick(dungeon)}
-                                       >
-                                               <ZeldaIcon name={state[dungeon].cswitch
-                                                       ? `crystal-switch-${state[dungeon].cswitch}`
-                                                       : 'crystal-switch'
-                                               } />
-                                       </div>
-                                       {ITEMS.map(item =>
-                                               <div
-                                                       className={
-                                                               `cell ${state[dungeon][item] ? 'on' : 'off'} ${ITEM_CLASSES[item]}`
-                                                       }
-                                                       key={item}
-                                                       onClick={handleItemClick(dungeon, item)}
-                                               >
-                                                       <ZeldaIcon name={item} />
-                                               </div>
-                                       )}
-                               </div>
-                       )}
-               </div>
-       </>;
-};
diff --git a/resources/js/pages/DoorsTracker.jsx b/resources/js/pages/DoorsTracker.jsx
new file mode 100644 (file)
index 0000000..087a249
--- /dev/null
@@ -0,0 +1,186 @@
+import React from 'react';
+import { Helmet } from 'react-helmet';
+
+import ZeldaIcon from '../components/common/ZeldaIcon';
+
+const DUNGEONS = [
+       'hc',
+       'ct',
+       'ep',
+       'dp',
+       'th',
+       'pd',
+       'sp',
+       'sw',
+       'tt',
+       'ip',
+       'mm',
+       'tr',
+       'gt',
+];
+
+const ITEMS = [
+       'compass',
+       'map',
+       'big-key',
+       'bow',
+       'hookshot',
+       'fire-rod',
+       'lamp',
+       'hammer',
+       'somaria',
+       'fighter-sword',
+       'boots',
+       'glove',
+       'flippers',
+];
+
+const ITEM_CLASSES = {
+       'compass': 'dungeon-item',
+       'map': 'dungeon-item',
+       'big-key': 'dungeon-item',
+       'bow': 'item',
+       'hookshot': 'item',
+       'fire-rod': 'item',
+       'lamp': 'item',
+       'hammer': 'item',
+       'somaria': 'item',
+       'fighter-sword': 'item',
+       'boots': 'item',
+       'glove': 'item',
+       'flippers': 'item',
+};
+
+const nextCSwitch = cur => {
+       switch (cur) {
+               case 'blue':
+                       return 'red';
+               case 'red':
+                       return '';
+               default:
+                       return 'blue';
+       }
+};
+
+const prevCSwitch = cur => nextCSwitch(nextCSwitch(cur));
+
+export const Component = () => {
+       const [state, setState] = React.useState(DUNGEONS.reduce((state, dungeon) => ({
+               ...state,
+               [dungeon]: ITEMS.reduce((items, item) => ({
+                       ...items,
+                       [item]: false,
+               }), {
+                       boss: true,
+                       cswitch: '',
+                       keys: 1,
+               }),
+       }), {}));
+
+       const handleItemClick = React.useCallback((dungeon, item) => e => {
+               setState(state => ({
+                       ...state,
+                       [dungeon]: {
+                               ...state[dungeon],
+                               [item]: !state[dungeon][item],
+                       },
+               }));
+               e.preventDefault();
+               e.stopPropagation();
+       });
+
+       const handleCSwitchClick = React.useCallback(dungeon => e => {
+               setState(state => ({
+                       ...state,
+                       [dungeon]: {
+                               ...state[dungeon],
+                               cswitch: nextCSwitch(state[dungeon].cswitch),
+                       },
+               }));
+               e.preventDefault();
+               e.stopPropagation();
+       });
+
+       const handleCSwitchRightClick = React.useCallback(dungeon => e => {
+               setState(state => ({
+                       ...state,
+                       [dungeon]: {
+                               ...state[dungeon],
+                               cswitch: prevCSwitch(state[dungeon].cswitch),
+                       },
+               }));
+               e.preventDefault();
+               e.stopPropagation();
+       });
+
+       const handleKeysClick = React.useCallback(dungeon => e => {
+               setState(state => ({
+                       ...state,
+                       [dungeon]: {
+                               ...state[dungeon],
+                               keys: state[dungeon].keys + 1,
+                       },
+               }));
+               e.preventDefault();
+               e.stopPropagation();
+       });
+
+       const handleKeysRightClick = React.useCallback(dungeon => e => {
+               setState(state => ({
+                       ...state,
+                       [dungeon]: {
+                               ...state[dungeon],
+                               keys: Math.max(state[dungeon].keys - 1, 0),
+                       },
+               }));
+               e.preventDefault();
+               e.stopPropagation();
+       });
+
+       return <>
+               <Helmet>
+                       <title>Doors Tracker</title>
+                       <meta name="description" content="Doors Tracker" />
+               </Helmet>
+               <div className="doors-tracker d-flex flex-column">
+                       {DUNGEONS.map(dungeon =>
+                               <div className="d-flex flex-row" key={dungeon}>
+                                       <div
+                                               className={`cell ${state[dungeon].boss ? 'on' : 'off'} dungeon`}
+                                               onClick={handleItemClick(dungeon, 'boss')}
+                                       >
+                                               <ZeldaIcon name={`dungeon-${dungeon}`} />
+                                       </div>
+                                       <div
+                                               className={`cell ${state[dungeon].keys ? 'on' : 'off'} keys`}
+                                               onClick={handleKeysClick(dungeon)}
+                                               onContextMenu={handleKeysRightClick(dungeon)}
+                                       >
+                                               {state[dungeon].keys}
+                                       </div>
+                                       <div
+                                               className={`cell ${state[dungeon].cswitch ? 'on' : 'off'} cswitch`}
+                                               onClick={handleCSwitchClick(dungeon)}
+                                               onContextMenu={handleCSwitchRightClick(dungeon)}
+                                       >
+                                               <ZeldaIcon name={state[dungeon].cswitch
+                                                       ? `crystal-switch-${state[dungeon].cswitch}`
+                                                       : 'crystal-switch'
+                                               } />
+                                       </div>
+                                       {ITEMS.map(item =>
+                                               <div
+                                                       className={
+                                                               `cell ${state[dungeon][item] ? 'on' : 'off'} ${ITEM_CLASSES[item]}`
+                                                       }
+                                                       key={item}
+                                                       onClick={handleItemClick(dungeon, item)}
+                                               >
+                                                       <ZeldaIcon name={item} />
+                                               </div>
+                                       )}
+                               </div>
+                       )}
+               </div>
+       </>;
+};
diff --git a/resources/js/pages/Event.js b/resources/js/pages/Event.js
deleted file mode 100644 (file)
index ef52e9f..0000000
+++ /dev/null
@@ -1,193 +0,0 @@
-import axios from 'axios';
-import moment from 'moment';
-import React from 'react';
-import { Alert, Button, Container } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-import { useParams } from 'react-router-dom';
-import toastr from 'toastr';
-
-import NotFound from './NotFound';
-import CanonicalLinks from '../components/common/CanonicalLinks';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import ErrorMessage from '../components/common/ErrorMessage';
-import Icon from '../components/common/Icon';
-import Loading from '../components/common/Loading';
-import EpisodeList from '../components/episodes/List';
-import Detail from '../components/events/Detail';
-import Dialog from '../components/techniques/Dialog';
-import { hasConcluded } from '../helpers/Event';
-import {
-       mayEditContent,
-} from '../helpers/permissions';
-import { getTranslation } from '../helpers/Technique';
-import { useUser } from '../hooks/user';
-import i18n from '../i18n';
-
-export const Component = () => {
-       const params = useParams();
-       const { name } = params;
-       const { user } = useUser();
-       const { t } = useTranslation();
-
-       const [error, setError] = React.useState(null);
-       const [loading, setLoading] = React.useState(true);
-       const [event, setEvent] = React.useState(null);
-
-       const [editContent, setEditContent] = React.useState(null);
-       const [episodes, setEpisodes] = React.useState([]);
-       const [pastMode, setPastMode] = React.useState(false);
-       const [showContentDialog, setShowContentDialog] = React.useState(false);
-
-       const actions = React.useMemo(() => ({
-               editContent: mayEditContent(user) ? content => {
-                       setEditContent(content);
-                       setShowContentDialog(true);
-               } : null,
-       }), [user]);
-
-       const fetchEpisodes = React.useCallback((controller, event) => {
-               if (!event) {
-                       setEpisodes([]);
-                       return;
-               }
-               const params = {
-                       event: [event.id],
-               };
-               if (hasConcluded(event)) {
-                       params.limit = 25;
-                       params.reverse = '1';
-               } else if (pastMode) {
-                       params.before = moment().add(3, 'hours').toISOString();
-                       params.limit = 25;
-                       params.reverse = '1';
-               } else {
-                       params.after = moment().subtract(3, 'hours').toISOString();
-                       params.before = moment().add(14, 'days').toISOString();
-               }
-               axios.get(`/api/episodes`, {
-                       signal: controller.signal,
-                       params,
-               }).then(response => {
-                       setEpisodes(response.data || []);
-               }).catch(e => {
-                       if (!axios.isCancel(e)) {
-                               console.error(e);
-                       }
-               });
-       }, [pastMode]);
-
-       const saveContent = React.useCallback(async values => {
-               try {
-                       const response = await axios.put(`/api/content/${values.id}`, {
-                               parent_id: event.description_id,
-                               ...values,
-                       });
-                       toastr.success(t('content.saveSuccess'));
-                       setEvent(event => ({
-                               ...event,
-                               description: response.data,
-                       }));
-                       setShowContentDialog(false);
-               } catch (e) {
-                       toastr.error(t('content.saveError'));
-               }
-       }, [event && event.description_id]);
-
-       React.useEffect(() => {
-               const ctrl = new AbortController();
-               setLoading(true);
-               axios
-                       .get(`/api/events/${name}`, { signal: ctrl.signal })
-                       .then(response => {
-                               setError(null);
-                               setLoading(false);
-                               setEvent(response.data);
-                       })
-                       .catch(error => {
-                               setError(error);
-                               setLoading(false);
-                               setEvent(null);
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [name]);
-
-       React.useEffect(() => {
-               const controller = new AbortController();
-               fetchEpisodes(controller, event);
-               const timer = setInterval(() => {
-                       fetchEpisodes(controller, event);
-               }, 1.5 * 60 * 1000);
-               return () => {
-                       controller.abort();
-                       clearInterval(timer);
-               };
-       }, [event, fetchEpisodes]);
-
-       if (loading) {
-               return <Loading />;
-       }
-
-       if (error) {
-               return <ErrorMessage error={error} />;
-       }
-
-       if (!event) {
-               return <NotFound />;
-       }
-
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>
-                               {(event.description && getTranslation(event.description, 'title', i18n.language))
-                                       || event.title}
-                       </title>
-               </Helmet>
-               {event.description ? <Helmet>
-                       <meta
-                               name="description"
-                               content={getTranslation(event.description, 'short', i18n.language)}
-                       />
-               </Helmet> : null}
-               <CanonicalLinks base={`/events/${event.name}`} />
-               <Container>
-                       <Detail actions={actions} event={event} />
-                       <div className="d-flex align-items-center justify-content-between">
-                               <h2 className="mt-4">
-                                       {t(pastMode || hasConcluded(event)
-                                               ? 'events.pastEpisodes'
-                                               : 'events.upcomingEpisodes'
-                                       )}
-                               </h2>
-                               <div className="button-bar">
-                                       {!hasConcluded(event) ?
-                                               <Button
-                                                       className="ms-3"
-                                                       onClick={() => setPastMode(!pastMode)}
-                                                       title={t(pastMode ? 'events.setFutureMode' : 'events.setPastMode')}
-                                                       variant="outline-secondary"
-                                               >
-                                                       <Icon.TIME_REVERSE title="" />
-                                               </Button>
-                                       : null}
-                               </div>
-                       </div>
-                       {episodes.length ?
-                               <EpisodeList episodes={episodes} />
-                       :
-                               <Alert variant="info">
-                                       {t(pastMode ? 'events.noPastEpisodes' : 'events.noUpcomingEpisodes')}
-                               </Alert>
-                       }
-               </Container>
-               <Dialog
-                       content={editContent}
-                       language={i18n.language}
-                       onHide={() => { setShowContentDialog(false); }}
-                       onSubmit={saveContent}
-                       show={showContentDialog}
-               />
-       </ErrorBoundary>;
-};
diff --git a/resources/js/pages/Event.jsx b/resources/js/pages/Event.jsx
new file mode 100644 (file)
index 0000000..ef52e9f
--- /dev/null
@@ -0,0 +1,193 @@
+import axios from 'axios';
+import moment from 'moment';
+import React from 'react';
+import { Alert, Button, Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router-dom';
+import toastr from 'toastr';
+
+import NotFound from './NotFound';
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Icon from '../components/common/Icon';
+import Loading from '../components/common/Loading';
+import EpisodeList from '../components/episodes/List';
+import Detail from '../components/events/Detail';
+import Dialog from '../components/techniques/Dialog';
+import { hasConcluded } from '../helpers/Event';
+import {
+       mayEditContent,
+} from '../helpers/permissions';
+import { getTranslation } from '../helpers/Technique';
+import { useUser } from '../hooks/user';
+import i18n from '../i18n';
+
+export const Component = () => {
+       const params = useParams();
+       const { name } = params;
+       const { user } = useUser();
+       const { t } = useTranslation();
+
+       const [error, setError] = React.useState(null);
+       const [loading, setLoading] = React.useState(true);
+       const [event, setEvent] = React.useState(null);
+
+       const [editContent, setEditContent] = React.useState(null);
+       const [episodes, setEpisodes] = React.useState([]);
+       const [pastMode, setPastMode] = React.useState(false);
+       const [showContentDialog, setShowContentDialog] = React.useState(false);
+
+       const actions = React.useMemo(() => ({
+               editContent: mayEditContent(user) ? content => {
+                       setEditContent(content);
+                       setShowContentDialog(true);
+               } : null,
+       }), [user]);
+
+       const fetchEpisodes = React.useCallback((controller, event) => {
+               if (!event) {
+                       setEpisodes([]);
+                       return;
+               }
+               const params = {
+                       event: [event.id],
+               };
+               if (hasConcluded(event)) {
+                       params.limit = 25;
+                       params.reverse = '1';
+               } else if (pastMode) {
+                       params.before = moment().add(3, 'hours').toISOString();
+                       params.limit = 25;
+                       params.reverse = '1';
+               } else {
+                       params.after = moment().subtract(3, 'hours').toISOString();
+                       params.before = moment().add(14, 'days').toISOString();
+               }
+               axios.get(`/api/episodes`, {
+                       signal: controller.signal,
+                       params,
+               }).then(response => {
+                       setEpisodes(response.data || []);
+               }).catch(e => {
+                       if (!axios.isCancel(e)) {
+                               console.error(e);
+                       }
+               });
+       }, [pastMode]);
+
+       const saveContent = React.useCallback(async values => {
+               try {
+                       const response = await axios.put(`/api/content/${values.id}`, {
+                               parent_id: event.description_id,
+                               ...values,
+                       });
+                       toastr.success(t('content.saveSuccess'));
+                       setEvent(event => ({
+                               ...event,
+                               description: response.data,
+                       }));
+                       setShowContentDialog(false);
+               } catch (e) {
+                       toastr.error(t('content.saveError'));
+               }
+       }, [event && event.description_id]);
+
+       React.useEffect(() => {
+               const ctrl = new AbortController();
+               setLoading(true);
+               axios
+                       .get(`/api/events/${name}`, { signal: ctrl.signal })
+                       .then(response => {
+                               setError(null);
+                               setLoading(false);
+                               setEvent(response.data);
+                       })
+                       .catch(error => {
+                               setError(error);
+                               setLoading(false);
+                               setEvent(null);
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [name]);
+
+       React.useEffect(() => {
+               const controller = new AbortController();
+               fetchEpisodes(controller, event);
+               const timer = setInterval(() => {
+                       fetchEpisodes(controller, event);
+               }, 1.5 * 60 * 1000);
+               return () => {
+                       controller.abort();
+                       clearInterval(timer);
+               };
+       }, [event, fetchEpisodes]);
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       if (!event) {
+               return <NotFound />;
+       }
+
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>
+                               {(event.description && getTranslation(event.description, 'title', i18n.language))
+                                       || event.title}
+                       </title>
+               </Helmet>
+               {event.description ? <Helmet>
+                       <meta
+                               name="description"
+                               content={getTranslation(event.description, 'short', i18n.language)}
+                       />
+               </Helmet> : null}
+               <CanonicalLinks base={`/events/${event.name}`} />
+               <Container>
+                       <Detail actions={actions} event={event} />
+                       <div className="d-flex align-items-center justify-content-between">
+                               <h2 className="mt-4">
+                                       {t(pastMode || hasConcluded(event)
+                                               ? 'events.pastEpisodes'
+                                               : 'events.upcomingEpisodes'
+                                       )}
+                               </h2>
+                               <div className="button-bar">
+                                       {!hasConcluded(event) ?
+                                               <Button
+                                                       className="ms-3"
+                                                       onClick={() => setPastMode(!pastMode)}
+                                                       title={t(pastMode ? 'events.setFutureMode' : 'events.setPastMode')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       <Icon.TIME_REVERSE title="" />
+                                               </Button>
+                                       : null}
+                               </div>
+                       </div>
+                       {episodes.length ?
+                               <EpisodeList episodes={episodes} />
+                       :
+                               <Alert variant="info">
+                                       {t(pastMode ? 'events.noPastEpisodes' : 'events.noUpcomingEpisodes')}
+                               </Alert>
+                       }
+               </Container>
+               <Dialog
+                       content={editContent}
+                       language={i18n.language}
+                       onHide={() => { setShowContentDialog(false); }}
+                       onSubmit={saveContent}
+                       show={showContentDialog}
+               />
+       </ErrorBoundary>;
+};
diff --git a/resources/js/pages/Events.js b/resources/js/pages/Events.js
deleted file mode 100644 (file)
index b65652e..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-import axios from 'axios';
-import React from 'react';
-import { Container } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-
-import CanonicalLinks from '../components/common/CanonicalLinks';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import ErrorMessage from '../components/common/ErrorMessage';
-import Loading from '../components/common/Loading';
-import List from '../components/events/List';
-import { compareStart, hasConcluded, isEvergreen, isOngoing } from '../helpers/Event';
-
-export const Component = () => {
-       const { t } = useTranslation();
-
-       const [error, setError] = React.useState(null);
-       const [loading, setLoading] = React.useState(true);
-       const [events, setEvents] = React.useState([]);
-
-       const fetchEvents = React.useCallback(async (controller) => {
-               const params = {
-                       order: 'recency',
-                       with: ['description'],
-               };
-               try {
-                       const response = await axios.get(`/api/events`, {
-                               signal: controller.signal,
-                               params,
-                       });
-                       return response.data || [];
-               } catch (error) {
-                       if (!axios.isCancel(error)) {
-                               throw error;
-                       }
-                       return [];
-               }
-       }, []);
-
-       React.useEffect(() => {
-               const controller = new AbortController();
-               setLoading(true);
-               fetchEvents(controller)
-                       .then(events => {
-                               setError(null);
-                               setLoading(false);
-                               setEvents(events);
-                       })
-                       .catch(error => {
-                               setError(error);
-                               setLoading(false);
-                               setEvents([]);
-                       });
-               return () => {
-                       controller.abort();
-               };
-       }, [fetchEvents]);
-
-       const evergreen = React.useMemo(() =>
-               events.filter(isEvergreen)
-       , [events]);
-       const ongoing = React.useMemo(() =>
-               events.filter(isOngoing).sort((a, b) => compareStart(a, b) * -1)
-       , [events]);
-       const past = React.useMemo(() =>
-               events.filter(hasConcluded)
-       , [events]);
-
-       if (loading) {
-               return <Loading />;
-       }
-
-       if (error) {
-               return <ErrorMessage error={error} />;
-       }
-
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>
-                               {t('events.heading')}
-                       </title>
-               </Helmet>
-               <CanonicalLinks base={`/events`} />
-               <Container>
-                       <h1>{t('events.ongoing')}</h1>
-                       <List events={ongoing} />
-                       <h1>{t('events.evergreen')}</h1>
-                       <List events={evergreen} />
-                       <h1>{t('events.past')}</h1>
-                       <List events={past} />
-               </Container>
-       </ErrorBoundary>;
-};
diff --git a/resources/js/pages/Events.jsx b/resources/js/pages/Events.jsx
new file mode 100644 (file)
index 0000000..b65652e
--- /dev/null
@@ -0,0 +1,93 @@
+import axios from 'axios';
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Loading from '../components/common/Loading';
+import List from '../components/events/List';
+import { compareStart, hasConcluded, isEvergreen, isOngoing } from '../helpers/Event';
+
+export const Component = () => {
+       const { t } = useTranslation();
+
+       const [error, setError] = React.useState(null);
+       const [loading, setLoading] = React.useState(true);
+       const [events, setEvents] = React.useState([]);
+
+       const fetchEvents = React.useCallback(async (controller) => {
+               const params = {
+                       order: 'recency',
+                       with: ['description'],
+               };
+               try {
+                       const response = await axios.get(`/api/events`, {
+                               signal: controller.signal,
+                               params,
+                       });
+                       return response.data || [];
+               } catch (error) {
+                       if (!axios.isCancel(error)) {
+                               throw error;
+                       }
+                       return [];
+               }
+       }, []);
+
+       React.useEffect(() => {
+               const controller = new AbortController();
+               setLoading(true);
+               fetchEvents(controller)
+                       .then(events => {
+                               setError(null);
+                               setLoading(false);
+                               setEvents(events);
+                       })
+                       .catch(error => {
+                               setError(error);
+                               setLoading(false);
+                               setEvents([]);
+                       });
+               return () => {
+                       controller.abort();
+               };
+       }, [fetchEvents]);
+
+       const evergreen = React.useMemo(() =>
+               events.filter(isEvergreen)
+       , [events]);
+       const ongoing = React.useMemo(() =>
+               events.filter(isOngoing).sort((a, b) => compareStart(a, b) * -1)
+       , [events]);
+       const past = React.useMemo(() =>
+               events.filter(hasConcluded)
+       , [events]);
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>
+                               {t('events.heading')}
+                       </title>
+               </Helmet>
+               <CanonicalLinks base={`/events`} />
+               <Container>
+                       <h1>{t('events.ongoing')}</h1>
+                       <List events={ongoing} />
+                       <h1>{t('events.evergreen')}</h1>
+                       <List events={evergreen} />
+                       <h1>{t('events.past')}</h1>
+                       <List events={past} />
+               </Container>
+       </ErrorBoundary>;
+};
diff --git a/resources/js/pages/Front.js b/resources/js/pages/Front.js
deleted file mode 100644 (file)
index f0485ed..0000000
+++ /dev/null
@@ -1,83 +0,0 @@
-import React from 'react';
-import { Button, Col, Container, Image, Row } from 'react-bootstrap';
-import { useTranslation } from 'react-i18next';
-import { Link, useNavigate } from 'react-router-dom';
-
-import CanonicalLinks from '../components/common/CanonicalLinks';
-
-const Front = () => {
-       const navigate = useNavigate();
-       const { t } = useTranslation();
-
-       React.useEffect(() => {
-               const returnPath = localStorage.getItem('returnPath');
-               if (returnPath) {
-                       localStorage.removeItem('returnPath');
-                       navigate(returnPath);
-               }
-       }, []);
-
-       return <Container className="front-page">
-               <CanonicalLinks base="/" />
-               <h1>{t('front.title')}</h1>
-               <h2>{t('front.tournaments')}</h2>
-               <Row>
-                       <Col sm={6}>
-                               <Link className="front-panel" to="/tournaments/6">
-                                       <Image alt="" className="image" src="/media/alttp/front.png" />
-                                       <div className="title">
-                                               {t('front.sdw')}
-                                       </div>
-                               </Link>
-                       </Col>
-                       <Col sm={6}>
-                               <Link className="front-panel" to="/tournaments/6">
-                                       <Image alt="" className="image" src="/media/alttp/front.png" />
-                                       <div className="title">
-                                               {t('front.circus')}
-                                       </div>
-                               </Link>
-                       </Col>
-               </Row>
-               <h2>{t('front.events')}</h2>
-               <Row>
-                       <Col sm={6}>
-                               <Link className="front-panel" to="/events">
-                                       <Image alt="" className="image" src="/media/alttp/front.png" />
-                                       <div className="title">
-                                               {t('front.eventlist')}
-                                       </div>
-                               </Link>
-                       </Col>
-                       <Col sm={6}>
-                               <Link className="front-panel" to="/schedule">
-                                       <Image alt="" className="image" src="/media/alttp/front.png" />
-                                       <div className="title">
-                                               {t('front.schedule')}
-                                       </div>
-                               </Link>
-                       </Col>
-               </Row>
-               <h2>{t('front.resources')}</h2>
-               <Row>
-                       <Col sm={6}>
-                               <Link className="front-panel" to="/tech">
-                                       <Image alt="" className="image" src="/media/alttp/front.png" />
-                                       <div className="title">
-                                               {t('front.tech')}
-                                       </div>
-                               </Link>
-                       </Col>
-                       <Col sm={6}>
-                               <Link className="front-panel" to="/map/lw">
-                                       <Image alt="" className="image" src="/media/alttp/front.png" />
-                                       <div className="title">
-                                               {t('front.map')}
-                                       </div>
-                               </Link>
-                       </Col>
-               </Row>
-       </Container>;
-};
-
-export default Front;
diff --git a/resources/js/pages/Front.jsx b/resources/js/pages/Front.jsx
new file mode 100644 (file)
index 0000000..f0485ed
--- /dev/null
@@ -0,0 +1,83 @@
+import React from 'react';
+import { Button, Col, Container, Image, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import { Link, useNavigate } from 'react-router-dom';
+
+import CanonicalLinks from '../components/common/CanonicalLinks';
+
+const Front = () => {
+       const navigate = useNavigate();
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               const returnPath = localStorage.getItem('returnPath');
+               if (returnPath) {
+                       localStorage.removeItem('returnPath');
+                       navigate(returnPath);
+               }
+       }, []);
+
+       return <Container className="front-page">
+               <CanonicalLinks base="/" />
+               <h1>{t('front.title')}</h1>
+               <h2>{t('front.tournaments')}</h2>
+               <Row>
+                       <Col sm={6}>
+                               <Link className="front-panel" to="/tournaments/6">
+                                       <Image alt="" className="image" src="/media/alttp/front.png" />
+                                       <div className="title">
+                                               {t('front.sdw')}
+                                       </div>
+                               </Link>
+                       </Col>
+                       <Col sm={6}>
+                               <Link className="front-panel" to="/tournaments/6">
+                                       <Image alt="" className="image" src="/media/alttp/front.png" />
+                                       <div className="title">
+                                               {t('front.circus')}
+                                       </div>
+                               </Link>
+                       </Col>
+               </Row>
+               <h2>{t('front.events')}</h2>
+               <Row>
+                       <Col sm={6}>
+                               <Link className="front-panel" to="/events">
+                                       <Image alt="" className="image" src="/media/alttp/front.png" />
+                                       <div className="title">
+                                               {t('front.eventlist')}
+                                       </div>
+                               </Link>
+                       </Col>
+                       <Col sm={6}>
+                               <Link className="front-panel" to="/schedule">
+                                       <Image alt="" className="image" src="/media/alttp/front.png" />
+                                       <div className="title">
+                                               {t('front.schedule')}
+                                       </div>
+                               </Link>
+                       </Col>
+               </Row>
+               <h2>{t('front.resources')}</h2>
+               <Row>
+                       <Col sm={6}>
+                               <Link className="front-panel" to="/tech">
+                                       <Image alt="" className="image" src="/media/alttp/front.png" />
+                                       <div className="title">
+                                               {t('front.tech')}
+                                       </div>
+                               </Link>
+                       </Col>
+                       <Col sm={6}>
+                               <Link className="front-panel" to="/map/lw">
+                                       <Image alt="" className="image" src="/media/alttp/front.png" />
+                                       <div className="title">
+                                               {t('front.map')}
+                                       </div>
+                               </Link>
+                       </Col>
+               </Row>
+       </Container>;
+};
+
+export default Front;
diff --git a/resources/js/pages/GuessingGameControls.js b/resources/js/pages/GuessingGameControls.js
deleted file mode 100644 (file)
index 192e399..0000000
+++ /dev/null
@@ -1,176 +0,0 @@
-import axios from 'axios';
-import React from 'react';
-import { Alert, Col, Container, Form, Navbar, Row } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-import { useParams } from 'react-router-dom';
-import toastr from 'toastr';
-
-import User from '../app/User';
-import ChannelSelect from '../components/common/ChannelSelect';
-import GuessingGameControls from '../components/twitch-bot/GuessingGameControls';
-import GuessingGuess from '../components/twitch-bot/GuessingGuess';
-import GuessingWinner from '../components/twitch-bot/GuessingWinner';
-import { patchGuess, patchWinner } from '../helpers/Channel';
-
-export const Component = () => {
-       const [channel, setChannel] = React.useState(null);
-       const [guesses, setGuesses] = React.useState([]);
-       const [winners, setWinners] = React.useState([]);
-
-       const { channelId } = useParams();
-       const { t } = useTranslation();
-
-       React.useEffect(() => {
-               if (!channelId) return;
-               const fetchChannel = async () => {
-                       const response = await axios.get(`/api/channels`, {
-                               params: {
-                                       id: [channelId],
-                                       manageable: 1,
-                               },
-                       });
-                       if (response.data.length) {
-                               setChannel(response.data[0]);
-                       }
-               };
-               fetchChannel();
-       }, [channelId]);
-
-       React.useEffect(() => {
-               if (!channel) {
-                       setGuesses([]);
-                       setWinners([]);
-                       return;
-               }
-               if (channel.guessing_type) {
-                       axios.get(`/api/channels/${channel.id}/guessing-game/${channel.guessing_type}`)
-                               .then(res => {
-                                       res.data.guesses.forEach(g => {
-                                               setGuesses(gs => patchGuess(gs, g));
-                                       });
-                                       res.data.winners.forEach(w => {
-                                               setWinners(ws => patchGuess(ws, w));
-                                       });
-                               });
-               }
-               window.Echo.private(`Channel.${channel.id}`)
-                       .listen('.GuessingGuessCreated', (e) => {
-                               setGuesses(gs => patchGuess(gs, e.model));
-                       })
-                       .listen('.GuessingWinnerCreated', (e) => {
-                               setWinners(ws => patchWinner(ws, e.model));
-                       })
-                       .listen('.ChannelUpdated', (e) => {
-                               setChannel(c => ({ ...c, ...e.model }));
-                       });
-               return () => {
-                       window.Echo.leave(`Channel.${channel.id}`);
-               };
-       }, [channel && channel.id]);
-
-       React.useEffect(() => {
-               const cutoff = channel && channel.guessing_start;
-               if (cutoff) {
-                       setGuesses(gs => gs.filter(g => g.created_at >= cutoff));
-                       setWinners(ws => ws.filter(w => w.created_at >= cutoff));
-               }
-       }, [channel && channel.guessing_start]);
-
-       const onCancel = React.useCallback(async () => {
-               try {
-                       const rsp = await axios.post(
-                               `/api/channels/${channel.id}/guessing-game/gtbk`,
-                               { action: 'cancel' },
-                       );
-                       setChannel(rsp.data);
-               } catch (e) {
-                       toastr.error(t('twitchBot.controlError'));
-               }
-       }, [channel]);
-
-       const onSolve = React.useCallback(async (solution) => {
-               try {
-                       const rsp = await axios.post(
-                               `/api/channels/${channel.id}/guessing-game/gtbk`,
-                               { action: 'solve', solution },
-                       );
-                       setChannel(rsp.data);
-               } catch (e) {
-                       toastr.error(t('twitchBot.controlError'));
-               }
-       }, [channel]);
-
-       const onStart = React.useCallback(async () => {
-               try {
-                       const rsp = await axios.post(
-                               `/api/channels/${channel.id}/guessing-game/gtbk`,
-                               { action: 'start' },
-                       );
-                       setChannel(rsp.data);
-               } catch (e) {
-                       toastr.error(t('twitchBot.controlError'));
-               }
-       }, [channel]);
-
-       const onStop = React.useCallback(async () => {
-               try {
-                       const rsp = await axios.post(
-                               `/api/channels/${channel.id}/guessing-game/gtbk`,
-                               { action: 'stop' },
-                       );
-                       setChannel(rsp.data);
-               } catch (e) {
-                       toastr.error(t('twitchBot.controlError'));
-               }
-       }, [channel]);
-
-       return <>
-               <Helmet>
-                       <title>Guessing Game Controls</title>
-               </Helmet>
-               <Navbar id="header" bg="dark" variant="dark">
-                       <Container fluid>
-                               <Form.Control
-                                       as={ChannelSelect}
-                                       autoSelect
-                                       joinable
-                                       manageable
-                                       onChange={({ channel }) => { setChannel(channel); }}
-                                       readOnly={!!(channelId && channel)}
-                                       value={channel ? channel.id : channelId}
-                               />
-                               <User />
-                       </Container>
-               </Navbar>
-               <Container fluid>
-               {channel ? <Row>
-                       <Col md={12} lg={6}>
-                               <GuessingGameControls
-                                       channel={channel}
-                                       onCancel={onCancel}
-                                       onSolve={onSolve}
-                                       onStart={onStart}
-                                       onStop={onStop}
-                               />
-                       </Col>
-                       <Col md={6} lg={3}>
-                               <h3 className="mt-3">{t('twitchBot.guessingGame.winners')}</h3>
-                               {winners.map(winner =>
-                                       <GuessingWinner key={winner.id} winner={winner} />
-                               )}
-                       </Col>
-                       <Col md={6} lg={3}>
-                               <h3 className="mt-3">{t('twitchBot.guessingGame.guesses')}</h3>
-                               {guesses.map(guess =>
-                                       <GuessingGuess guess={guess} key={guess.id} />
-                               )}
-                       </Col>
-               </Row> :
-                       <Alert variant="info">
-                               {t('twitchBot.selectChannel')}
-                       </Alert>
-               }
-               </Container>
-       </>;
-};
diff --git a/resources/js/pages/GuessingGameControls.jsx b/resources/js/pages/GuessingGameControls.jsx
new file mode 100644 (file)
index 0000000..192e399
--- /dev/null
@@ -0,0 +1,176 @@
+import axios from 'axios';
+import React from 'react';
+import { Alert, Col, Container, Form, Navbar, Row } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router-dom';
+import toastr from 'toastr';
+
+import User from '../app/User';
+import ChannelSelect from '../components/common/ChannelSelect';
+import GuessingGameControls from '../components/twitch-bot/GuessingGameControls';
+import GuessingGuess from '../components/twitch-bot/GuessingGuess';
+import GuessingWinner from '../components/twitch-bot/GuessingWinner';
+import { patchGuess, patchWinner } from '../helpers/Channel';
+
+export const Component = () => {
+       const [channel, setChannel] = React.useState(null);
+       const [guesses, setGuesses] = React.useState([]);
+       const [winners, setWinners] = React.useState([]);
+
+       const { channelId } = useParams();
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               if (!channelId) return;
+               const fetchChannel = async () => {
+                       const response = await axios.get(`/api/channels`, {
+                               params: {
+                                       id: [channelId],
+                                       manageable: 1,
+                               },
+                       });
+                       if (response.data.length) {
+                               setChannel(response.data[0]);
+                       }
+               };
+               fetchChannel();
+       }, [channelId]);
+
+       React.useEffect(() => {
+               if (!channel) {
+                       setGuesses([]);
+                       setWinners([]);
+                       return;
+               }
+               if (channel.guessing_type) {
+                       axios.get(`/api/channels/${channel.id}/guessing-game/${channel.guessing_type}`)
+                               .then(res => {
+                                       res.data.guesses.forEach(g => {
+                                               setGuesses(gs => patchGuess(gs, g));
+                                       });
+                                       res.data.winners.forEach(w => {
+                                               setWinners(ws => patchGuess(ws, w));
+                                       });
+                               });
+               }
+               window.Echo.private(`Channel.${channel.id}`)
+                       .listen('.GuessingGuessCreated', (e) => {
+                               setGuesses(gs => patchGuess(gs, e.model));
+                       })
+                       .listen('.GuessingWinnerCreated', (e) => {
+                               setWinners(ws => patchWinner(ws, e.model));
+                       })
+                       .listen('.ChannelUpdated', (e) => {
+                               setChannel(c => ({ ...c, ...e.model }));
+                       });
+               return () => {
+                       window.Echo.leave(`Channel.${channel.id}`);
+               };
+       }, [channel && channel.id]);
+
+       React.useEffect(() => {
+               const cutoff = channel && channel.guessing_start;
+               if (cutoff) {
+                       setGuesses(gs => gs.filter(g => g.created_at >= cutoff));
+                       setWinners(ws => ws.filter(w => w.created_at >= cutoff));
+               }
+       }, [channel && channel.guessing_start]);
+
+       const onCancel = React.useCallback(async () => {
+               try {
+                       const rsp = await axios.post(
+                               `/api/channels/${channel.id}/guessing-game/gtbk`,
+                               { action: 'cancel' },
+                       );
+                       setChannel(rsp.data);
+               } catch (e) {
+                       toastr.error(t('twitchBot.controlError'));
+               }
+       }, [channel]);
+
+       const onSolve = React.useCallback(async (solution) => {
+               try {
+                       const rsp = await axios.post(
+                               `/api/channels/${channel.id}/guessing-game/gtbk`,
+                               { action: 'solve', solution },
+                       );
+                       setChannel(rsp.data);
+               } catch (e) {
+                       toastr.error(t('twitchBot.controlError'));
+               }
+       }, [channel]);
+
+       const onStart = React.useCallback(async () => {
+               try {
+                       const rsp = await axios.post(
+                               `/api/channels/${channel.id}/guessing-game/gtbk`,
+                               { action: 'start' },
+                       );
+                       setChannel(rsp.data);
+               } catch (e) {
+                       toastr.error(t('twitchBot.controlError'));
+               }
+       }, [channel]);
+
+       const onStop = React.useCallback(async () => {
+               try {
+                       const rsp = await axios.post(
+                               `/api/channels/${channel.id}/guessing-game/gtbk`,
+                               { action: 'stop' },
+                       );
+                       setChannel(rsp.data);
+               } catch (e) {
+                       toastr.error(t('twitchBot.controlError'));
+               }
+       }, [channel]);
+
+       return <>
+               <Helmet>
+                       <title>Guessing Game Controls</title>
+               </Helmet>
+               <Navbar id="header" bg="dark" variant="dark">
+                       <Container fluid>
+                               <Form.Control
+                                       as={ChannelSelect}
+                                       autoSelect
+                                       joinable
+                                       manageable
+                                       onChange={({ channel }) => { setChannel(channel); }}
+                                       readOnly={!!(channelId && channel)}
+                                       value={channel ? channel.id : channelId}
+                               />
+                               <User />
+                       </Container>
+               </Navbar>
+               <Container fluid>
+               {channel ? <Row>
+                       <Col md={12} lg={6}>
+                               <GuessingGameControls
+                                       channel={channel}
+                                       onCancel={onCancel}
+                                       onSolve={onSolve}
+                                       onStart={onStart}
+                                       onStop={onStop}
+                               />
+                       </Col>
+                       <Col md={6} lg={3}>
+                               <h3 className="mt-3">{t('twitchBot.guessingGame.winners')}</h3>
+                               {winners.map(winner =>
+                                       <GuessingWinner key={winner.id} winner={winner} />
+                               )}
+                       </Col>
+                       <Col md={6} lg={3}>
+                               <h3 className="mt-3">{t('twitchBot.guessingGame.guesses')}</h3>
+                               {guesses.map(guess =>
+                                       <GuessingGuess guess={guess} key={guess.id} />
+                               )}
+                       </Col>
+               </Row> :
+                       <Alert variant="info">
+                               {t('twitchBot.selectChannel')}
+                       </Alert>
+               }
+               </Container>
+       </>;
+};
diff --git a/resources/js/pages/GuessingGameMonitor.js b/resources/js/pages/GuessingGameMonitor.js
deleted file mode 100644 (file)
index bf2922b..0000000
+++ /dev/null
@@ -1,194 +0,0 @@
-import axios from 'axios';
-import moment from 'moment';
-import React from 'react';
-import { Container } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useParams } from 'react-router-dom';
-
-import {
-       hasActiveGuessing,
-       isAcceptingGuesses,
-       patchGuess,
-       patchWinner,
-} from '../helpers/Channel';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import Icon from '../components/common/Icon';
-import Slider from '../components/common/Slider';
-
-export const Component = () => {
-       const [channel, setChannel] = React.useState({});
-       const [guesses, setGuesses] = React.useState([]);
-       const [winnerExpiry, setWinnerExpiry] = React.useState(moment().subtract(15, 'second'));
-       const [winners, setWinners] = React.useState([]);
-
-       const params = useParams();
-       const { key } = params;
-
-       React.useEffect(() => {
-               if (!key) return;
-               axios.get(`/api/guessing-game-monitor/${key}`)
-                       .then(res => {
-                               setChannel(res.data.channel);
-                               res.data.guesses.forEach(g => {
-                                       setGuesses(gs => patchGuess(gs, g));
-                               });
-                               res.data.winners.forEach(w => {
-                                       setWinners(ws => patchGuess(ws, w));
-                               });
-                       });
-               window.Echo.channel(`ChannelKey.${key}`)
-                       .listen('.GuessingGuessCreated', (e) => {
-                               setGuesses(gs => patchGuess(gs, e.model));
-                       })
-                       .listen('.GuessingWinnerCreated', (e) => {
-                               setWinners(ws => patchWinner(ws, e.model));
-                       })
-                       .listen('.ChannelUpdated', (e) => {
-                               setChannel(c => ({ ...c, ...e.model }));
-                       });
-               return () => {
-                       window.Echo.leave(`ChannelKey.${key}`);
-               };
-       }, [key]);
-
-       React.useEffect(() => {
-               if (isAcceptingGuesses(channel)) {
-                       setGuesses(gs => gs.filter(g => g.created_at >= channel.guessing_start));
-                       setWinners([]);
-               }
-       }, [channel]);
-
-       React.useEffect(() => {
-               const interval = setInterval(() => {
-                       setWinnerExpiry(moment().subtract(15, 'second'));
-               }, 1000);
-               return () => {
-                       clearInterval(interval);
-               };
-       }, []);
-
-       const guessingStats = React.useMemo(() => {
-               const stats = {
-                       counts: [],
-                       lastWin: null,
-                       max: 0,
-                       wins: [],
-                       winners: [],
-               };
-               for (let i = 0; i < 22; ++i) {
-                       stats.counts.push(0);
-                       stats.wins.push(false);
-               }
-               const seen = [];
-               guesses.forEach(guess => {
-                       if (seen[guess.uid]) {
-                               --stats.counts[parseInt(seen[guess.uid].guess, 10) - 1];
-                       }
-                       ++stats.counts[parseInt(guess.guess, 10) - 1];
-                       seen[guess.uid] = guess;
-               });
-               winners.forEach(winner => {
-                       if (winner.score) {
-                               stats.wins[parseInt(winner.guess, 10) - 1] = true;
-                               stats.winners.push(winner.uname);
-                       }
-                       if (!stats.lastWin || stats.lastWin < winner.created_at) {
-                               stats.lastWin = winner.created_at;
-                       }
-               });
-               for (let i = 0; i < 22; ++i) {
-                       if (stats.counts[i] > stats.max) {
-                               stats.max = stats.counts[i];
-                       }
-               }
-               return stats;
-       }, [guesses, winners]);
-
-       const getNumberHeight = React.useCallback((number) => {
-               if (!guessingStats || !guessingStats.max) return 3;
-               if (!number) return 3;
-               return Math.max(0.05, number / guessingStats.max) * 100;
-       }, [guessingStats]);
-
-       const getStatClass = React.useCallback((index) => {
-               const names = ['guessing-stat'];
-               if (guessingStats.wins[index]) {
-                       names.push('has-won');
-               }
-               return names.join(' ');
-       }, [guessingStats]);
-
-       const showOpen = React.useMemo(() => {
-               return isAcceptingGuesses(channel);
-       }, [channel]);
-
-       const showClosed = React.useMemo(() => {
-               return hasActiveGuessing(channel) && !isAcceptingGuesses(channel);
-       }, [channel]);
-
-       const showWinners = React.useMemo(() => {
-               return !hasActiveGuessing(channel) && (
-                       guessingStats?.lastWin &&
-                       moment(guessingStats.lastWin).isAfter(winnerExpiry));
-       }, [channel, guessingStats, winnerExpiry]);
-
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>Guessing Game</title>
-               </Helmet>
-               <Container className="guessing-game-monitor" fluid>
-               {showOpen || showClosed || showWinners ?
-                       <div className="message-box">
-                               {showOpen ?
-                                       <div className="message-title accepting-guesses">
-                                               <Icon.WARNING className="message-icon" />
-                                               <div className="message-text">
-                                                       <Slider duration={3500}>
-                                                               <Slider.Slide>GT Big Key Guessing Game</Slider.Slide>
-                                                               <Slider.Slide>Zahlen von 1 bis 22 in den Chat!</Slider.Slide>
-                                                       </Slider>
-                                               </div>
-                                               <Icon.WARNING className="message-icon" />
-                                       </div>
-                               : null}
-                               {showClosed ?
-                                       <div className="message-title guessing-closed">
-                                               <div className="message-text">
-                                                       Anmeldung geschlossen
-                                               </div>
-                                       </div>
-                               : null}
-                               {showWinners ?
-                                       <div className="message-title guessing-winners">
-                                               <div className="message-text">
-                                                       {guessingStats.winners.length ?
-                                                               <Slider duration={2500}>
-                                                                       <Slider.Slide>Herzlichen Glückwunsch!</Slider.Slide>
-                                                                       {guessingStats.winners.map(winner =>
-                                                                               <Slider.Slide key={winner}>{winner}</Slider.Slide>
-                                                                       )}
-                                                               </Slider>
-                                                       :
-                                                               'Leider keiner richtig'
-                                                       }
-                                               </div>
-                                       </div>
-                               : null}
-                               <div className="guessing-stats">
-                                       {guessingStats.counts.map((number, index) =>
-                                               <div className={getStatClass(index)} key={index}>
-                                                       <div className="guessing-box">
-                                                               <div
-                                                                       className="guessing-box-bar"
-                                                                       style={{ height: `${getNumberHeight(number)}%` }}
-                                                               />
-                                                       </div>
-                                                       <div className="guessing-number">{index + 1}</div>
-                                               </div>
-                                       )}
-                               </div>
-                       </div>
-               : null}
-               </Container>
-       </ErrorBoundary>;
-};
diff --git a/resources/js/pages/GuessingGameMonitor.jsx b/resources/js/pages/GuessingGameMonitor.jsx
new file mode 100644 (file)
index 0000000..bf2922b
--- /dev/null
@@ -0,0 +1,194 @@
+import axios from 'axios';
+import moment from 'moment';
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useParams } from 'react-router-dom';
+
+import {
+       hasActiveGuessing,
+       isAcceptingGuesses,
+       patchGuess,
+       patchWinner,
+} from '../helpers/Channel';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import Icon from '../components/common/Icon';
+import Slider from '../components/common/Slider';
+
+export const Component = () => {
+       const [channel, setChannel] = React.useState({});
+       const [guesses, setGuesses] = React.useState([]);
+       const [winnerExpiry, setWinnerExpiry] = React.useState(moment().subtract(15, 'second'));
+       const [winners, setWinners] = React.useState([]);
+
+       const params = useParams();
+       const { key } = params;
+
+       React.useEffect(() => {
+               if (!key) return;
+               axios.get(`/api/guessing-game-monitor/${key}`)
+                       .then(res => {
+                               setChannel(res.data.channel);
+                               res.data.guesses.forEach(g => {
+                                       setGuesses(gs => patchGuess(gs, g));
+                               });
+                               res.data.winners.forEach(w => {
+                                       setWinners(ws => patchGuess(ws, w));
+                               });
+                       });
+               window.Echo.channel(`ChannelKey.${key}`)
+                       .listen('.GuessingGuessCreated', (e) => {
+                               setGuesses(gs => patchGuess(gs, e.model));
+                       })
+                       .listen('.GuessingWinnerCreated', (e) => {
+                               setWinners(ws => patchWinner(ws, e.model));
+                       })
+                       .listen('.ChannelUpdated', (e) => {
+                               setChannel(c => ({ ...c, ...e.model }));
+                       });
+               return () => {
+                       window.Echo.leave(`ChannelKey.${key}`);
+               };
+       }, [key]);
+
+       React.useEffect(() => {
+               if (isAcceptingGuesses(channel)) {
+                       setGuesses(gs => gs.filter(g => g.created_at >= channel.guessing_start));
+                       setWinners([]);
+               }
+       }, [channel]);
+
+       React.useEffect(() => {
+               const interval = setInterval(() => {
+                       setWinnerExpiry(moment().subtract(15, 'second'));
+               }, 1000);
+               return () => {
+                       clearInterval(interval);
+               };
+       }, []);
+
+       const guessingStats = React.useMemo(() => {
+               const stats = {
+                       counts: [],
+                       lastWin: null,
+                       max: 0,
+                       wins: [],
+                       winners: [],
+               };
+               for (let i = 0; i < 22; ++i) {
+                       stats.counts.push(0);
+                       stats.wins.push(false);
+               }
+               const seen = [];
+               guesses.forEach(guess => {
+                       if (seen[guess.uid]) {
+                               --stats.counts[parseInt(seen[guess.uid].guess, 10) - 1];
+                       }
+                       ++stats.counts[parseInt(guess.guess, 10) - 1];
+                       seen[guess.uid] = guess;
+               });
+               winners.forEach(winner => {
+                       if (winner.score) {
+                               stats.wins[parseInt(winner.guess, 10) - 1] = true;
+                               stats.winners.push(winner.uname);
+                       }
+                       if (!stats.lastWin || stats.lastWin < winner.created_at) {
+                               stats.lastWin = winner.created_at;
+                       }
+               });
+               for (let i = 0; i < 22; ++i) {
+                       if (stats.counts[i] > stats.max) {
+                               stats.max = stats.counts[i];
+                       }
+               }
+               return stats;
+       }, [guesses, winners]);
+
+       const getNumberHeight = React.useCallback((number) => {
+               if (!guessingStats || !guessingStats.max) return 3;
+               if (!number) return 3;
+               return Math.max(0.05, number / guessingStats.max) * 100;
+       }, [guessingStats]);
+
+       const getStatClass = React.useCallback((index) => {
+               const names = ['guessing-stat'];
+               if (guessingStats.wins[index]) {
+                       names.push('has-won');
+               }
+               return names.join(' ');
+       }, [guessingStats]);
+
+       const showOpen = React.useMemo(() => {
+               return isAcceptingGuesses(channel);
+       }, [channel]);
+
+       const showClosed = React.useMemo(() => {
+               return hasActiveGuessing(channel) && !isAcceptingGuesses(channel);
+       }, [channel]);
+
+       const showWinners = React.useMemo(() => {
+               return !hasActiveGuessing(channel) && (
+                       guessingStats?.lastWin &&
+                       moment(guessingStats.lastWin).isAfter(winnerExpiry));
+       }, [channel, guessingStats, winnerExpiry]);
+
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>Guessing Game</title>
+               </Helmet>
+               <Container className="guessing-game-monitor" fluid>
+               {showOpen || showClosed || showWinners ?
+                       <div className="message-box">
+                               {showOpen ?
+                                       <div className="message-title accepting-guesses">
+                                               <Icon.WARNING className="message-icon" />
+                                               <div className="message-text">
+                                                       <Slider duration={3500}>
+                                                               <Slider.Slide>GT Big Key Guessing Game</Slider.Slide>
+                                                               <Slider.Slide>Zahlen von 1 bis 22 in den Chat!</Slider.Slide>
+                                                       </Slider>
+                                               </div>
+                                               <Icon.WARNING className="message-icon" />
+                                       </div>
+                               : null}
+                               {showClosed ?
+                                       <div className="message-title guessing-closed">
+                                               <div className="message-text">
+                                                       Anmeldung geschlossen
+                                               </div>
+                                       </div>
+                               : null}
+                               {showWinners ?
+                                       <div className="message-title guessing-winners">
+                                               <div className="message-text">
+                                                       {guessingStats.winners.length ?
+                                                               <Slider duration={2500}>
+                                                                       <Slider.Slide>Herzlichen Glückwunsch!</Slider.Slide>
+                                                                       {guessingStats.winners.map(winner =>
+                                                                               <Slider.Slide key={winner}>{winner}</Slider.Slide>
+                                                                       )}
+                                                               </Slider>
+                                                       :
+                                                               'Leider keiner richtig'
+                                                       }
+                                               </div>
+                                       </div>
+                               : null}
+                               <div className="guessing-stats">
+                                       {guessingStats.counts.map((number, index) =>
+                                               <div className={getStatClass(index)} key={index}>
+                                                       <div className="guessing-box">
+                                                               <div
+                                                                       className="guessing-box-bar"
+                                                                       style={{ height: `${getNumberHeight(number)}%` }}
+                                                               />
+                                                       </div>
+                                                       <div className="guessing-number">{index + 1}</div>
+                                               </div>
+                                       )}
+                               </div>
+                       </div>
+               : null}
+               </Container>
+       </ErrorBoundary>;
+};
diff --git a/resources/js/pages/HorstieLog.js b/resources/js/pages/HorstieLog.js
deleted file mode 100644 (file)
index 25661e0..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-import axios from 'axios';
-import React from 'react';
-import { Col, Container, Row } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-
-import ChannelList from '../components/channel/List';
-import List from '../components/chat-bot-logs/List';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import ErrorMessage from '../components/common/ErrorMessage';
-import Loading from '../components/common/Loading';
-import { compareHorstieLog } from '../helpers/Channel';
-
-export const Component = () => {
-       const [channels, setChannels] = React.useState([]);
-       const [error, setError] = React.useState(null);
-       const [loading, setLoading] = React.useState(true);
-       const [log, setLog] = React.useState([]);
-
-       const { t } = useTranslation();
-
-       React.useEffect(() => {
-               const ctrl = new AbortController();
-               if (!log.length) {
-                       setLoading(true);
-               }
-               axios
-                       .get(`/api/chatbotlogs/`, {
-                               signal: ctrl.signal,
-                       })
-                       .then(response => {
-                               setError(null);
-                               setLoading(false);
-                               setLog(response.data);
-                       })
-                       .catch(error => {
-                               if (!axios.isCancel(error)) {
-                                       setError(error);
-                                       setLoading(false);
-                                       setLog([]);
-                               }
-                       });
-                       window.Echo.channel(`ChatBotLog`)
-                               .listen('.ChatBotLogCreated', (e) => {
-                                       setLog(l => [e.model, ...l]);
-                               });
-               return () => {
-                       ctrl.abort();
-                       window.Echo.leave(`ChatBotLog`);
-               };
-       }, []);
-
-       React.useEffect(() => {
-               const ctrl = new AbortController();
-               axios
-                       .get(`/api/channels`, {
-                               signal: ctrl.signal,
-                               params: {
-                                       chatting: 1,
-                               },
-                       })
-                       .then(response => {
-                               setChannels(response.data.sort(compareHorstieLog));
-                       })
-                       .catch(error => {
-                               if (!axios.isCancel(error)) {
-                                       setChannels([]);
-                               }
-                       });
-                       window.Echo.channel(`Channel`)
-                               .listen('.ChannelCreated', (e) => {
-                                       if (e.model.chat) {
-                                               setChannels(cs => [e.model, ...cs].sort(compareHorstieLog));
-                                       }
-                               })
-                               .listen('.ChannelUpdated', (e) => {
-                                       if (e.model.chat) {
-                                               setChannels(cs => {
-                                                       if (cs.find(c => c.id === e.model.id)) {
-                                                               return cs
-                                                                       .map(c => c.id === e.model.id ? e.model : c)
-                                                                       .sort(compareHorstieLog);
-                                                       } else {
-                                                               return [e.model, ...cs].sort(compareHorstieLog);
-                                                       }
-                                               });
-                                       } else {
-                                               setChannels(cs => cs.filter(c => c.id !== e.model.id));
-                                       }
-                               });
-               return () => {
-                       ctrl.abort();
-               };
-       }, []);
-
-       if (loading) {
-               return <Loading />;
-       }
-
-       if (error) {
-               return <ErrorMessage error={error} />;
-       }
-
-       return <Container>
-               <h1>Horstie Log</h1>
-               <Helmet>
-                       <title>Horstie Log</title>
-               </Helmet>
-               <ErrorBoundary>
-                       <Row>
-                               <Col md={9}>
-                                       <List log={log} />
-                               </Col>
-                               <Col className="horstielog-channels">
-                                       <h2 className="fs-4">{t('twitchBot.chatChannels')}</h2>
-                                       <ChannelList channels={channels} />
-                               </Col>
-                       </Row>
-               </ErrorBoundary>
-       </Container>;
-};
diff --git a/resources/js/pages/HorstieLog.jsx b/resources/js/pages/HorstieLog.jsx
new file mode 100644 (file)
index 0000000..25661e0
--- /dev/null
@@ -0,0 +1,121 @@
+import axios from 'axios';
+import React from 'react';
+import { Col, Container, Row } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+
+import ChannelList from '../components/channel/List';
+import List from '../components/chat-bot-logs/List';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Loading from '../components/common/Loading';
+import { compareHorstieLog } from '../helpers/Channel';
+
+export const Component = () => {
+       const [channels, setChannels] = React.useState([]);
+       const [error, setError] = React.useState(null);
+       const [loading, setLoading] = React.useState(true);
+       const [log, setLog] = React.useState([]);
+
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               const ctrl = new AbortController();
+               if (!log.length) {
+                       setLoading(true);
+               }
+               axios
+                       .get(`/api/chatbotlogs/`, {
+                               signal: ctrl.signal,
+                       })
+                       .then(response => {
+                               setError(null);
+                               setLoading(false);
+                               setLog(response.data);
+                       })
+                       .catch(error => {
+                               if (!axios.isCancel(error)) {
+                                       setError(error);
+                                       setLoading(false);
+                                       setLog([]);
+                               }
+                       });
+                       window.Echo.channel(`ChatBotLog`)
+                               .listen('.ChatBotLogCreated', (e) => {
+                                       setLog(l => [e.model, ...l]);
+                               });
+               return () => {
+                       ctrl.abort();
+                       window.Echo.leave(`ChatBotLog`);
+               };
+       }, []);
+
+       React.useEffect(() => {
+               const ctrl = new AbortController();
+               axios
+                       .get(`/api/channels`, {
+                               signal: ctrl.signal,
+                               params: {
+                                       chatting: 1,
+                               },
+                       })
+                       .then(response => {
+                               setChannels(response.data.sort(compareHorstieLog));
+                       })
+                       .catch(error => {
+                               if (!axios.isCancel(error)) {
+                                       setChannels([]);
+                               }
+                       });
+                       window.Echo.channel(`Channel`)
+                               .listen('.ChannelCreated', (e) => {
+                                       if (e.model.chat) {
+                                               setChannels(cs => [e.model, ...cs].sort(compareHorstieLog));
+                                       }
+                               })
+                               .listen('.ChannelUpdated', (e) => {
+                                       if (e.model.chat) {
+                                               setChannels(cs => {
+                                                       if (cs.find(c => c.id === e.model.id)) {
+                                                               return cs
+                                                                       .map(c => c.id === e.model.id ? e.model : c)
+                                                                       .sort(compareHorstieLog);
+                                                       } else {
+                                                               return [e.model, ...cs].sort(compareHorstieLog);
+                                                       }
+                                               });
+                                       } else {
+                                               setChannels(cs => cs.filter(c => c.id !== e.model.id));
+                                       }
+                               });
+               return () => {
+                       ctrl.abort();
+               };
+       }, []);
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       return <Container>
+               <h1>Horstie Log</h1>
+               <Helmet>
+                       <title>Horstie Log</title>
+               </Helmet>
+               <ErrorBoundary>
+                       <Row>
+                               <Col md={9}>
+                                       <List log={log} />
+                               </Col>
+                               <Col className="horstielog-channels">
+                                       <h2 className="fs-4">{t('twitchBot.chatChannels')}</h2>
+                                       <ChannelList channels={channels} />
+                               </Col>
+                       </Row>
+               </ErrorBoundary>
+       </Container>;
+};
diff --git a/resources/js/pages/Map.js b/resources/js/pages/Map.js
deleted file mode 100644 (file)
index 1ffd45a..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import { Container } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-import { useParams } from 'react-router';
-
-import CanonicalLinks from '../components/common/CanonicalLinks';
-import Buttons from '../components/map/Buttons';
-import List from '../components/map/List';
-import OpenSeadragon from '../components/map/OpenSeadragon';
-import Pins from '../components/map/Pins';
-import UWSuperTiles from '../components/map/UWSuperTiles';
-
-export const Component = () => {
-       const [uwOverlay, setUWOverlay] = React.useState(false);
-
-       const { activeMap } = useParams();
-       const container = React.useRef();
-       const { t } = useTranslation();
-
-       return <Container fluid>
-               <Helmet>
-                       <title>{t('map.heading')} - {t(`map.${activeMap}Long`)}</title>
-                       <meta name="description" content={t('map.description')} />
-               </Helmet>
-               <CanonicalLinks base={`/map/${activeMap}`} />
-               <OpenSeadragon containerRef={container}>
-                       <div className="d-flex align-items-start justify-content-between">
-                               <h1>{t('map.heading')} - {t(`map.${activeMap}Long`)}</h1>
-                               <Buttons setUWOverlay={setUWOverlay} uwOverlay={uwOverlay} />
-                       </div>
-                       <div ref={container} style={{ height: '80vh' }} />
-                       <Pins />
-                       <UWSuperTiles show={uwOverlay} />
-                       <List />
-               </OpenSeadragon>
-       </Container>;
-};
diff --git a/resources/js/pages/Map.jsx b/resources/js/pages/Map.jsx
new file mode 100644 (file)
index 0000000..1ffd45a
--- /dev/null
@@ -0,0 +1,38 @@
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router';
+
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import Buttons from '../components/map/Buttons';
+import List from '../components/map/List';
+import OpenSeadragon from '../components/map/OpenSeadragon';
+import Pins from '../components/map/Pins';
+import UWSuperTiles from '../components/map/UWSuperTiles';
+
+export const Component = () => {
+       const [uwOverlay, setUWOverlay] = React.useState(false);
+
+       const { activeMap } = useParams();
+       const container = React.useRef();
+       const { t } = useTranslation();
+
+       return <Container fluid>
+               <Helmet>
+                       <title>{t('map.heading')} - {t(`map.${activeMap}Long`)}</title>
+                       <meta name="description" content={t('map.description')} />
+               </Helmet>
+               <CanonicalLinks base={`/map/${activeMap}`} />
+               <OpenSeadragon containerRef={container}>
+                       <div className="d-flex align-items-start justify-content-between">
+                               <h1>{t('map.heading')} - {t(`map.${activeMap}Long`)}</h1>
+                               <Buttons setUWOverlay={setUWOverlay} uwOverlay={uwOverlay} />
+                       </div>
+                       <div ref={container} style={{ height: '80vh' }} />
+                       <Pins />
+                       <UWSuperTiles show={uwOverlay} />
+                       <List />
+               </OpenSeadragon>
+       </Container>;
+};
diff --git a/resources/js/pages/NotFound.js b/resources/js/pages/NotFound.js
deleted file mode 100644 (file)
index 19ccc72..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-import { Helmet } from 'react-helmet';
-
-const NotFound = () =>
-       <div>
-               <Helmet>
-                       <title>Not Found</title>
-               </Helmet>
-               <h1>Not Found</h1>
-               <p>Sorry</p>
-       </div>;
-
-export default NotFound;
diff --git a/resources/js/pages/NotFound.jsx b/resources/js/pages/NotFound.jsx
new file mode 100644 (file)
index 0000000..19ccc72
--- /dev/null
@@ -0,0 +1,13 @@
+import React from 'react';
+import { Helmet } from 'react-helmet';
+
+const NotFound = () =>
+       <div>
+               <Helmet>
+                       <title>Not Found</title>
+               </Helmet>
+               <h1>Not Found</h1>
+               <p>Sorry</p>
+       </div>;
+
+export default NotFound;
diff --git a/resources/js/pages/Schedule.js b/resources/js/pages/Schedule.js
deleted file mode 100644 (file)
index 2b6a623..0000000
+++ /dev/null
@@ -1,329 +0,0 @@
-import axios from 'axios';
-import moment from 'moment';
-import React from 'react';
-import { Alert, Button, Container } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-import toastr from 'toastr';
-
-import CanonicalLinks from '../components/common/CanonicalLinks';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import Icon from '../components/common/Icon';
-import ApplyDialog from '../components/episodes/ApplyDialog';
-import Filter from '../components/episodes/Filter';
-import List from '../components/episodes/List';
-import RestreamDialog from '../components/episodes/RestreamDialog';
-import { invertEventFilter, toggleEventFilter } from '../helpers/Episode';
-import { useUser } from '../hooks/user';
-
-export const Component = () => {
-       const [ahead] = React.useState(14);
-       const [applyAs, setApplyAs] = React.useState('commentary');
-       const [behind] = React.useState(0);
-       const [episodes, setEpisodes] = React.useState([]);
-       const [events, setEvents] = React.useState([]);
-       const [filter, setFilter] = React.useState({});
-       const [restreamChannel, setRestreamChannel] = React.useState(null);
-       const [restreamEpisode, setRestreamEpisode] = React.useState(null);
-       const [showApplyDialog, setShowApplyDialog] = React.useState(false);
-       const [showRestreamDialog, setShowRestreamDialog] = React.useState(false);
-       const [showFilter, setShowFilter] = React.useState(false);
-
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       React.useEffect(() => {
-               const savedFilter = localStorage.getItem('episodes.filter.schedule');
-               if (savedFilter) {
-                       setFilter(JSON.parse(savedFilter));
-               } else {
-                       setFilter(filter => filter ? {} : filter);
-               }
-       }, []);
-
-       const fetchEvents = React.useCallback((controller) => {
-               axios.get(`/api/events`, {
-                       signal: controller.signal,
-                       params: {
-                               after: moment().startOf('day').subtract(1, 'days').toISOString(),
-                               before: moment().startOf('day').add(8, 'days').toISOString(),
-                       },
-               }).then(response => {
-                       const newEvents = (response.data || []).sort(
-                               (a, b) => (a.short || a.title).localeCompare(b.short || b.title)
-                       );
-                       setEvents(newEvents);
-               }).catch(e => {
-                       if (!axios.isCancel(e)) {
-                               console.error(e);
-                       }
-               });
-       });
-
-       React.useEffect(() => {
-               const controller = new AbortController();
-               fetchEvents(controller);
-               const timer = setInterval(() => {
-                       fetchEvents(controller);
-                       clearInterval(timer);
-               }, 15 * 60 * 1000);
-               return () => {
-                       controller.abort();
-               };
-       }, []);
-
-       const updateFilter = React.useCallback(newFilter => {
-               localStorage.setItem('episodes.filter.schedule', JSON.stringify(newFilter));
-               setFilter(newFilter);
-       }, []);
-
-       const invertFilter = React.useCallback(() => {
-               updateFilter(invertEventFilter(filter));
-       }, [filter, updateFilter]);
-
-       const fetchEpisodes = React.useCallback((controller, ahead, behind, filter) => {
-               axios.get(`/api/episodes`, {
-                       signal: controller.signal,
-                       params: {
-                               after: moment().subtract(2, 'hours').subtract(behind, 'days').toISOString(),
-                               before: moment().add(16, 'hours').add(ahead, 'days').toISOString(),
-                               ...filter,
-                       },
-               }).then(response => {
-                       setEpisodes(response.data || []);
-               }).catch(e => {
-                       if (!axios.isCancel(e)) {
-                               console.error(e);
-                       }
-               });
-       }, []);
-
-       const onAddRestream = React.useCallback(episode => {
-               setRestreamEpisode(episode);
-               setShowRestreamDialog(true);
-       }, []);
-
-       const onAddRestreamSubmit = React.useCallback(async values => {
-               try {
-                       const response = await axios.post(
-                               `/api/episodes/${values.episode_id}/add-restream`, values);
-                       const newEpisode = response.data;
-                       setEpisodes(episodes => episodes.map(episode =>
-                               episode.id === newEpisode.id ? {
-                                       ...episode,
-                                       ...newEpisode,
-                               } : episode
-                       ));
-                       toastr.success(t('episodes.restreamDialog.addSuccess'));
-               } catch (e) {
-                       toastr.error(t('episodes.restreamDialog.addError'));
-                       throw e;
-               }
-               setRestreamEpisode(null);
-               setShowRestreamDialog(false);
-       }, []);
-
-       const onRemoveRestream = React.useCallback(async (episode, channel) => {
-               try {
-                       const response = await axios.post(
-                               `/api/episodes/${episode.id}/remove-restream`, { channel_id: channel.id });
-                       const newEpisode = response.data;
-                       setEpisodes(episodes => episodes.map(episode =>
-                               episode.id === newEpisode.id ? {
-                                       ...episode,
-                                       ...newEpisode,
-                               } : episode
-                       ));
-                       toastr.success(t('episodes.restreamDialog.removeSuccess'));
-                       setRestreamChannel(null);
-                       setRestreamEpisode(null);
-                       setShowRestreamDialog(false);
-               } catch (e) {
-                       toastr.error(t('episodes.restreamDialog.removeError'));
-               }
-       }, []);
-
-       const onEditRestream = React.useCallback((episode, channel) => {
-               setRestreamChannel(channel);
-               setRestreamEpisode(episode);
-               setShowRestreamDialog(true);
-       }, []);
-
-       const editRestream = React.useCallback(async values => {
-               try {
-                       const response = await axios.post(
-                               `/api/episodes/${values.episode_id}/edit-restream`, values);
-                       const newEpisode = response.data;
-                       setEpisodes(episodes => episodes.map(episode =>
-                               episode.id === newEpisode.id ? {
-                                       ...episode,
-                                       ...newEpisode,
-                               } : episode
-                       ));
-                       setRestreamEpisode(episode => ({
-                               ...episode,
-                               ...newEpisode,
-                       }));
-                       const newChannel = newEpisode.channels.find(c => c.id === values.channel_id);
-                       setRestreamChannel(channel => ({
-                               ...channel,
-                               ...newChannel,
-                       }));
-                       toastr.success(t('episodes.restreamDialog.editSuccess'));
-               } catch (e) {
-                       toastr.error(t('episodes.restreamDialog.editError'));
-               }
-       }, []);
-
-       const manageCrew = React.useCallback(async values => {
-               try {
-                       const response = await axios.post(
-                               `/api/episodes/${values.episode_id}/crew-manage`, values);
-                       const newEpisode = response.data;
-                       setEpisodes(episodes => episodes.map(episode =>
-                               episode.id === newEpisode.id ? {
-                                       ...episode,
-                                       ...newEpisode,
-                               } : episode
-                       ));
-                       setRestreamEpisode(episode => ({
-                               ...episode,
-                               ...newEpisode,
-                       }));
-                       const newChannel = newEpisode.channels.find(c => c.id === values.channel_id);
-                       setRestreamChannel(channel => ({
-                               ...channel,
-                               ...newChannel,
-                       }));
-                       toastr.success(t('episodes.restreamDialog.crewSuccess'));
-               } catch (e) {
-                       toastr.error(t('episodes.restreamDialog.crewError'));
-               }
-       }, []);
-
-       const onHideRestreamDialog = React.useCallback(() => {
-               setShowRestreamDialog(false);
-               setRestreamChannel(null);
-               setRestreamEpisode(null);
-       }, []);
-
-       const onApply = React.useCallback((episode, as) => {
-               setShowApplyDialog(true);
-               setRestreamEpisode(episode);
-               setApplyAs(as);
-       }, []);
-
-       const onSubmitApplyDialog = React.useCallback(async values => {
-               try {
-                       const response = await axios.post(
-                               `/api/episodes/${values.episode_id}/crew-signup`, values);
-                       const newEpisode = response.data;
-                       setEpisodes(episodes => episodes.map(episode =>
-                               episode.id === newEpisode.id ? {
-                                       ...episode,
-                                       ...newEpisode,
-                               } : episode
-                       ));
-                       toastr.success(t('episodes.applyDialog.applySuccess'));
-               } catch (e) {
-                       toastr.error(t('episodes.applyDialog.applyError'));
-                       throw e;
-               }
-               setRestreamEpisode(null);
-               setShowApplyDialog(false);
-       }, []);
-
-       const onHideApplyDialog = React.useCallback(() => {
-               setShowApplyDialog(false);
-               setRestreamEpisode(null);
-       }, []);
-
-       React.useEffect(() => {
-               const controller = new AbortController();
-               fetchEpisodes(controller, ahead, behind, filter);
-               const timer = setInterval(() => {
-                       fetchEpisodes(controller, ahead, behind, filter);
-               }, 1.5 * 60 * 1000);
-               return () => {
-                       controller.abort();
-                       clearInterval(timer);
-               };
-       }, [ahead, behind, fetchEpisodes, filter]);
-
-       const toggleFilter = React.useCallback(() => {
-               setShowFilter(show => !show);
-       }, []);
-
-       const filterButtonVariant = React.useMemo(() => {
-               const outline = showFilter ? '' : 'outline-';
-               const filterActive = filter && filter.event && filter.event.length;
-               return `${outline}${filterActive ? 'info' : 'secondary'}`;
-       }, [filter, showFilter]);
-
-       return <Container>
-               <Helmet>
-                       <title>{t('schedule.heading')}</title>
-                       <meta name="description" content={t('schedule.description')} />
-               </Helmet>
-               <CanonicalLinks base="/schedule" />
-               <div className="d-flex align-items-end justify-content-between">
-                       <h1 className="mb-0">{t('schedule.heading')}</h1>
-                       <div className="button-bar">
-                               {showFilter ?
-                                       <Button
-                                               onClick={invertFilter}
-                                               title={t('button.invert')}
-                                               variant="outline-secondary"
-                                       >
-                                               <Icon.INVERT title="" />
-                                       </Button>
-                               : null}
-                               <Button
-                                       onClick={toggleFilter}
-                                       title={t('button.filter')}
-                                       variant={filterButtonVariant}
-                               >
-                                       <Icon.FILTER title="" />
-                               </Button>
-                       </div>
-               </div>
-               {showFilter ?
-                       <div className="my-2">
-                               <Filter events={events} filter={filter} setFilter={updateFilter} />
-                       </div>
-               : null}
-               <ErrorBoundary>
-                       {episodes.length ?
-                               <List
-                                       episodes={episodes}
-                                       onAddRestream={onAddRestream}
-                                       onApply={onApply}
-                                       onEditRestream={onEditRestream}
-                               />
-                       :
-                               <Alert variant="info">
-                                       {t('episodes.empty')}
-                               </Alert>
-                       }
-               </ErrorBoundary>
-               {user ? <>
-                       <ApplyDialog
-                               as={applyAs}
-                               episode={restreamEpisode}
-                               onHide={onHideApplyDialog}
-                               onSubmit={onSubmitApplyDialog}
-                               show={showApplyDialog}
-                       />
-                       <RestreamDialog
-                               channel={restreamChannel}
-                               editRestream={editRestream}
-                               episode={restreamEpisode}
-                               manageCrew={manageCrew}
-                               onRemoveRestream={onRemoveRestream}
-                               onHide={onHideRestreamDialog}
-                               onSubmit={onAddRestreamSubmit}
-                               show={showRestreamDialog}
-                       />
-               </> : null}
-       </Container>;
-};
diff --git a/resources/js/pages/Schedule.jsx b/resources/js/pages/Schedule.jsx
new file mode 100644 (file)
index 0000000..2b6a623
--- /dev/null
@@ -0,0 +1,329 @@
+import axios from 'axios';
+import moment from 'moment';
+import React from 'react';
+import { Alert, Button, Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import Icon from '../components/common/Icon';
+import ApplyDialog from '../components/episodes/ApplyDialog';
+import Filter from '../components/episodes/Filter';
+import List from '../components/episodes/List';
+import RestreamDialog from '../components/episodes/RestreamDialog';
+import { invertEventFilter, toggleEventFilter } from '../helpers/Episode';
+import { useUser } from '../hooks/user';
+
+export const Component = () => {
+       const [ahead] = React.useState(14);
+       const [applyAs, setApplyAs] = React.useState('commentary');
+       const [behind] = React.useState(0);
+       const [episodes, setEpisodes] = React.useState([]);
+       const [events, setEvents] = React.useState([]);
+       const [filter, setFilter] = React.useState({});
+       const [restreamChannel, setRestreamChannel] = React.useState(null);
+       const [restreamEpisode, setRestreamEpisode] = React.useState(null);
+       const [showApplyDialog, setShowApplyDialog] = React.useState(false);
+       const [showRestreamDialog, setShowRestreamDialog] = React.useState(false);
+       const [showFilter, setShowFilter] = React.useState(false);
+
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       React.useEffect(() => {
+               const savedFilter = localStorage.getItem('episodes.filter.schedule');
+               if (savedFilter) {
+                       setFilter(JSON.parse(savedFilter));
+               } else {
+                       setFilter(filter => filter ? {} : filter);
+               }
+       }, []);
+
+       const fetchEvents = React.useCallback((controller) => {
+               axios.get(`/api/events`, {
+                       signal: controller.signal,
+                       params: {
+                               after: moment().startOf('day').subtract(1, 'days').toISOString(),
+                               before: moment().startOf('day').add(8, 'days').toISOString(),
+                       },
+               }).then(response => {
+                       const newEvents = (response.data || []).sort(
+                               (a, b) => (a.short || a.title).localeCompare(b.short || b.title)
+                       );
+                       setEvents(newEvents);
+               }).catch(e => {
+                       if (!axios.isCancel(e)) {
+                               console.error(e);
+                       }
+               });
+       });
+
+       React.useEffect(() => {
+               const controller = new AbortController();
+               fetchEvents(controller);
+               const timer = setInterval(() => {
+                       fetchEvents(controller);
+                       clearInterval(timer);
+               }, 15 * 60 * 1000);
+               return () => {
+                       controller.abort();
+               };
+       }, []);
+
+       const updateFilter = React.useCallback(newFilter => {
+               localStorage.setItem('episodes.filter.schedule', JSON.stringify(newFilter));
+               setFilter(newFilter);
+       }, []);
+
+       const invertFilter = React.useCallback(() => {
+               updateFilter(invertEventFilter(filter));
+       }, [filter, updateFilter]);
+
+       const fetchEpisodes = React.useCallback((controller, ahead, behind, filter) => {
+               axios.get(`/api/episodes`, {
+                       signal: controller.signal,
+                       params: {
+                               after: moment().subtract(2, 'hours').subtract(behind, 'days').toISOString(),
+                               before: moment().add(16, 'hours').add(ahead, 'days').toISOString(),
+                               ...filter,
+                       },
+               }).then(response => {
+                       setEpisodes(response.data || []);
+               }).catch(e => {
+                       if (!axios.isCancel(e)) {
+                               console.error(e);
+                       }
+               });
+       }, []);
+
+       const onAddRestream = React.useCallback(episode => {
+               setRestreamEpisode(episode);
+               setShowRestreamDialog(true);
+       }, []);
+
+       const onAddRestreamSubmit = React.useCallback(async values => {
+               try {
+                       const response = await axios.post(
+                               `/api/episodes/${values.episode_id}/add-restream`, values);
+                       const newEpisode = response.data;
+                       setEpisodes(episodes => episodes.map(episode =>
+                               episode.id === newEpisode.id ? {
+                                       ...episode,
+                                       ...newEpisode,
+                               } : episode
+                       ));
+                       toastr.success(t('episodes.restreamDialog.addSuccess'));
+               } catch (e) {
+                       toastr.error(t('episodes.restreamDialog.addError'));
+                       throw e;
+               }
+               setRestreamEpisode(null);
+               setShowRestreamDialog(false);
+       }, []);
+
+       const onRemoveRestream = React.useCallback(async (episode, channel) => {
+               try {
+                       const response = await axios.post(
+                               `/api/episodes/${episode.id}/remove-restream`, { channel_id: channel.id });
+                       const newEpisode = response.data;
+                       setEpisodes(episodes => episodes.map(episode =>
+                               episode.id === newEpisode.id ? {
+                                       ...episode,
+                                       ...newEpisode,
+                               } : episode
+                       ));
+                       toastr.success(t('episodes.restreamDialog.removeSuccess'));
+                       setRestreamChannel(null);
+                       setRestreamEpisode(null);
+                       setShowRestreamDialog(false);
+               } catch (e) {
+                       toastr.error(t('episodes.restreamDialog.removeError'));
+               }
+       }, []);
+
+       const onEditRestream = React.useCallback((episode, channel) => {
+               setRestreamChannel(channel);
+               setRestreamEpisode(episode);
+               setShowRestreamDialog(true);
+       }, []);
+
+       const editRestream = React.useCallback(async values => {
+               try {
+                       const response = await axios.post(
+                               `/api/episodes/${values.episode_id}/edit-restream`, values);
+                       const newEpisode = response.data;
+                       setEpisodes(episodes => episodes.map(episode =>
+                               episode.id === newEpisode.id ? {
+                                       ...episode,
+                                       ...newEpisode,
+                               } : episode
+                       ));
+                       setRestreamEpisode(episode => ({
+                               ...episode,
+                               ...newEpisode,
+                       }));
+                       const newChannel = newEpisode.channels.find(c => c.id === values.channel_id);
+                       setRestreamChannel(channel => ({
+                               ...channel,
+                               ...newChannel,
+                       }));
+                       toastr.success(t('episodes.restreamDialog.editSuccess'));
+               } catch (e) {
+                       toastr.error(t('episodes.restreamDialog.editError'));
+               }
+       }, []);
+
+       const manageCrew = React.useCallback(async values => {
+               try {
+                       const response = await axios.post(
+                               `/api/episodes/${values.episode_id}/crew-manage`, values);
+                       const newEpisode = response.data;
+                       setEpisodes(episodes => episodes.map(episode =>
+                               episode.id === newEpisode.id ? {
+                                       ...episode,
+                                       ...newEpisode,
+                               } : episode
+                       ));
+                       setRestreamEpisode(episode => ({
+                               ...episode,
+                               ...newEpisode,
+                       }));
+                       const newChannel = newEpisode.channels.find(c => c.id === values.channel_id);
+                       setRestreamChannel(channel => ({
+                               ...channel,
+                               ...newChannel,
+                       }));
+                       toastr.success(t('episodes.restreamDialog.crewSuccess'));
+               } catch (e) {
+                       toastr.error(t('episodes.restreamDialog.crewError'));
+               }
+       }, []);
+
+       const onHideRestreamDialog = React.useCallback(() => {
+               setShowRestreamDialog(false);
+               setRestreamChannel(null);
+               setRestreamEpisode(null);
+       }, []);
+
+       const onApply = React.useCallback((episode, as) => {
+               setShowApplyDialog(true);
+               setRestreamEpisode(episode);
+               setApplyAs(as);
+       }, []);
+
+       const onSubmitApplyDialog = React.useCallback(async values => {
+               try {
+                       const response = await axios.post(
+                               `/api/episodes/${values.episode_id}/crew-signup`, values);
+                       const newEpisode = response.data;
+                       setEpisodes(episodes => episodes.map(episode =>
+                               episode.id === newEpisode.id ? {
+                                       ...episode,
+                                       ...newEpisode,
+                               } : episode
+                       ));
+                       toastr.success(t('episodes.applyDialog.applySuccess'));
+               } catch (e) {
+                       toastr.error(t('episodes.applyDialog.applyError'));
+                       throw e;
+               }
+               setRestreamEpisode(null);
+               setShowApplyDialog(false);
+       }, []);
+
+       const onHideApplyDialog = React.useCallback(() => {
+               setShowApplyDialog(false);
+               setRestreamEpisode(null);
+       }, []);
+
+       React.useEffect(() => {
+               const controller = new AbortController();
+               fetchEpisodes(controller, ahead, behind, filter);
+               const timer = setInterval(() => {
+                       fetchEpisodes(controller, ahead, behind, filter);
+               }, 1.5 * 60 * 1000);
+               return () => {
+                       controller.abort();
+                       clearInterval(timer);
+               };
+       }, [ahead, behind, fetchEpisodes, filter]);
+
+       const toggleFilter = React.useCallback(() => {
+               setShowFilter(show => !show);
+       }, []);
+
+       const filterButtonVariant = React.useMemo(() => {
+               const outline = showFilter ? '' : 'outline-';
+               const filterActive = filter && filter.event && filter.event.length;
+               return `${outline}${filterActive ? 'info' : 'secondary'}`;
+       }, [filter, showFilter]);
+
+       return <Container>
+               <Helmet>
+                       <title>{t('schedule.heading')}</title>
+                       <meta name="description" content={t('schedule.description')} />
+               </Helmet>
+               <CanonicalLinks base="/schedule" />
+               <div className="d-flex align-items-end justify-content-between">
+                       <h1 className="mb-0">{t('schedule.heading')}</h1>
+                       <div className="button-bar">
+                               {showFilter ?
+                                       <Button
+                                               onClick={invertFilter}
+                                               title={t('button.invert')}
+                                               variant="outline-secondary"
+                                       >
+                                               <Icon.INVERT title="" />
+                                       </Button>
+                               : null}
+                               <Button
+                                       onClick={toggleFilter}
+                                       title={t('button.filter')}
+                                       variant={filterButtonVariant}
+                               >
+                                       <Icon.FILTER title="" />
+                               </Button>
+                       </div>
+               </div>
+               {showFilter ?
+                       <div className="my-2">
+                               <Filter events={events} filter={filter} setFilter={updateFilter} />
+                       </div>
+               : null}
+               <ErrorBoundary>
+                       {episodes.length ?
+                               <List
+                                       episodes={episodes}
+                                       onAddRestream={onAddRestream}
+                                       onApply={onApply}
+                                       onEditRestream={onEditRestream}
+                               />
+                       :
+                               <Alert variant="info">
+                                       {t('episodes.empty')}
+                               </Alert>
+                       }
+               </ErrorBoundary>
+               {user ? <>
+                       <ApplyDialog
+                               as={applyAs}
+                               episode={restreamEpisode}
+                               onHide={onHideApplyDialog}
+                               onSubmit={onSubmitApplyDialog}
+                               show={showApplyDialog}
+                       />
+                       <RestreamDialog
+                               channel={restreamChannel}
+                               editRestream={editRestream}
+                               episode={restreamEpisode}
+                               manageCrew={manageCrew}
+                               onRemoveRestream={onRemoveRestream}
+                               onHide={onHideRestreamDialog}
+                               onSubmit={onAddRestreamSubmit}
+                               show={showRestreamDialog}
+                       />
+               </> : null}
+       </Container>;
+};
diff --git a/resources/js/pages/Technique.js b/resources/js/pages/Technique.js
deleted file mode 100644 (file)
index b2da417..0000000
+++ /dev/null
@@ -1,122 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React, { useEffect, useState } from 'react';
-import { Helmet } from 'react-helmet';
-import { withTranslation } from 'react-i18next';
-import { useParams } from 'react-router-dom';
-import toastr from 'toastr';
-
-import CanonicalLinks from '../components/common/CanonicalLinks';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import ErrorMessage from '../components/common/ErrorMessage';
-import Loading from '../components/common/Loading';
-import NotFound from '../pages/NotFound';
-import Detail from '../components/techniques/Detail';
-import Dialog from '../components/techniques/Dialog';
-import {
-       mayEditContent,
-} from '../helpers/permissions';
-import { getLanguages, getMatchedLocale, getTranslation } from '../helpers/Technique';
-import { useUser } from '../hooks/user';
-import i18n from '../i18n';
-
-const Technique = ({ basepath, type }) => {
-       const params = useParams();
-       const { name } = params;
-       const { user } = useUser();
-
-       const [error, setError] = useState(null);
-       const [loading, setLoading] = useState(true);
-       const [technique, setTechnique] = useState(null);
-
-       const [editContent, setEditContent] = useState(null);
-       const [showContentDialog, setShowContentDialog] = useState(false);
-
-       const actions = React.useMemo(() => ({
-               editContent: mayEditContent(user) ? content => {
-                       setEditContent(content);
-                       setShowContentDialog(true);
-               } : null,
-       }), [user]);
-
-       const saveContent = React.useCallback(async values => {
-               try {
-                       const response = await axios.put(`/api/content/${values.id}`, {
-                               parent_id: technique.id,
-                               ...values,
-                       });
-                       toastr.success(i18n.t('content.saveSuccess'));
-                       setTechnique(response.data);
-                       setShowContentDialog(false);
-               } catch (e) {
-                       toastr.error(i18n.t('content.saveError'));
-               }
-       }, [technique && technique.id]);
-
-       useEffect(() => {
-               const ctrl = new AbortController();
-               setLoading(true);
-               axios
-                       .get(`/api/pages/${type}/${name}`, { signal: ctrl.signal })
-                       .then(response => {
-                               setError(null);
-                               setLoading(false);
-                               setTechnique(response.data);
-                       })
-                       .catch(error => {
-                               setError(error);
-                               setLoading(false);
-                               setTechnique(null);
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [name, type]);
-
-       if (loading) {
-               return <Loading />;
-       }
-
-       if (error) {
-               return <ErrorMessage error={error} />;
-       }
-
-       if (!technique) {
-               return <NotFound />;
-       }
-
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>{getTranslation(technique, 'title', i18n.language)}</title>
-                       <meta name="description" content={getTranslation(technique, 'short', i18n.language)} />
-               </Helmet>
-               {technique.image ? <Helmet>
-                       <meta property="og:image" content={technique.image} />
-                       <meta property="twitter:image" content={technique.image} />
-               </Helmet> : null}
-               {!technique.image && technique.gif ? <Helmet>
-                       <meta property="og:image" content={technique.gif} />
-                       <meta property="twitter:image" content={technique.gif} />
-               </Helmet> : null}
-               <CanonicalLinks
-                       base={`/${basepath}/${technique.name}`}
-                       lang={getMatchedLocale(technique, i18n.language)}
-                       langs={getLanguages(technique)}
-               />
-               <Detail actions={actions} technique={technique} />
-               <Dialog
-                       content={editContent}
-                       language={i18n.language}
-                       onHide={() => { setShowContentDialog(false); }}
-                       onSubmit={saveContent}
-                       show={showContentDialog}
-               />
-       </ErrorBoundary>;
-};
-
-Technique.propTypes = {
-       basepath: PropTypes.string,
-       type: PropTypes.string,
-};
-
-export default withTranslation()(Technique);
diff --git a/resources/js/pages/Technique.jsx b/resources/js/pages/Technique.jsx
new file mode 100644 (file)
index 0000000..b2da417
--- /dev/null
@@ -0,0 +1,122 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useEffect, useState } from 'react';
+import { Helmet } from 'react-helmet';
+import { withTranslation } from 'react-i18next';
+import { useParams } from 'react-router-dom';
+import toastr from 'toastr';
+
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Loading from '../components/common/Loading';
+import NotFound from '../pages/NotFound';
+import Detail from '../components/techniques/Detail';
+import Dialog from '../components/techniques/Dialog';
+import {
+       mayEditContent,
+} from '../helpers/permissions';
+import { getLanguages, getMatchedLocale, getTranslation } from '../helpers/Technique';
+import { useUser } from '../hooks/user';
+import i18n from '../i18n';
+
+const Technique = ({ basepath, type }) => {
+       const params = useParams();
+       const { name } = params;
+       const { user } = useUser();
+
+       const [error, setError] = useState(null);
+       const [loading, setLoading] = useState(true);
+       const [technique, setTechnique] = useState(null);
+
+       const [editContent, setEditContent] = useState(null);
+       const [showContentDialog, setShowContentDialog] = useState(false);
+
+       const actions = React.useMemo(() => ({
+               editContent: mayEditContent(user) ? content => {
+                       setEditContent(content);
+                       setShowContentDialog(true);
+               } : null,
+       }), [user]);
+
+       const saveContent = React.useCallback(async values => {
+               try {
+                       const response = await axios.put(`/api/content/${values.id}`, {
+                               parent_id: technique.id,
+                               ...values,
+                       });
+                       toastr.success(i18n.t('content.saveSuccess'));
+                       setTechnique(response.data);
+                       setShowContentDialog(false);
+               } catch (e) {
+                       toastr.error(i18n.t('content.saveError'));
+               }
+       }, [technique && technique.id]);
+
+       useEffect(() => {
+               const ctrl = new AbortController();
+               setLoading(true);
+               axios
+                       .get(`/api/pages/${type}/${name}`, { signal: ctrl.signal })
+                       .then(response => {
+                               setError(null);
+                               setLoading(false);
+                               setTechnique(response.data);
+                       })
+                       .catch(error => {
+                               setError(error);
+                               setLoading(false);
+                               setTechnique(null);
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [name, type]);
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       if (!technique) {
+               return <NotFound />;
+       }
+
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>{getTranslation(technique, 'title', i18n.language)}</title>
+                       <meta name="description" content={getTranslation(technique, 'short', i18n.language)} />
+               </Helmet>
+               {technique.image ? <Helmet>
+                       <meta property="og:image" content={technique.image} />
+                       <meta property="twitter:image" content={technique.image} />
+               </Helmet> : null}
+               {!technique.image && technique.gif ? <Helmet>
+                       <meta property="og:image" content={technique.gif} />
+                       <meta property="twitter:image" content={technique.gif} />
+               </Helmet> : null}
+               <CanonicalLinks
+                       base={`/${basepath}/${technique.name}`}
+                       lang={getMatchedLocale(technique, i18n.language)}
+                       langs={getLanguages(technique)}
+               />
+               <Detail actions={actions} technique={technique} />
+               <Dialog
+                       content={editContent}
+                       language={i18n.language}
+                       onHide={() => { setShowContentDialog(false); }}
+                       onSubmit={saveContent}
+                       show={showContentDialog}
+               />
+       </ErrorBoundary>;
+};
+
+Technique.propTypes = {
+       basepath: PropTypes.string,
+       type: PropTypes.string,
+};
+
+export default withTranslation()(Technique);
diff --git a/resources/js/pages/Techniques.js b/resources/js/pages/Techniques.js
deleted file mode 100644 (file)
index 0c5d5b0..0000000
+++ /dev/null
@@ -1,100 +0,0 @@
-import axios from 'axios';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Helmet } from 'react-helmet';
-import { withTranslation } from 'react-i18next';
-
-import NotFound from './NotFound';
-import CanonicalLinks from '../components/common/CanonicalLinks';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import ErrorMessage from '../components/common/ErrorMessage';
-import Loading from '../components/common/Loading';
-import Overview from '../components/techniques/Overview';
-import { compareTranslation } from '../helpers/Technique';
-import i18n from '../i18n';
-
-const Techniques = ({ namespace, type }) => {
-       const [error, setError] = React.useState(null);
-       const [filter, setFilter] = React.useState({});
-       const [loading, setLoading] = React.useState(true);
-       const [techniques, setTechniques] = React.useState([]);
-
-       React.useEffect(() => {
-               const savedFilter = localStorage.getItem(`content.filter.${type}`);
-               if (savedFilter) {
-                       setFilter(JSON.parse(savedFilter));
-               } else {
-                       setFilter(filter => filter ? {} : filter);
-               }
-       }, [type]);
-
-       const updateFilter = React.useCallback(newFilter => {
-               localStorage.setItem(`content.filter.${type}`, JSON.stringify(newFilter));
-               setFilter(newFilter);
-       }, [type]);
-
-       React.useEffect(() => {
-               const ctrl = new AbortController();
-               if (!techniques.length) {
-                       setLoading(true);
-               }
-               axios
-                       .get(`/api/pages/${type}`, {
-                               params: filter,
-                               signal: ctrl.signal
-                       })
-                       .then(response => {
-                               setError(null);
-                               setLoading(false);
-                               setTechniques(response.data.sort(compareTranslation('title', i18n.language)));
-                       })
-                       .catch(error => {
-                               if (!axios.isCancel(error)) {
-                                       setError(error);
-                                       setLoading(false);
-                                       setTechniques([]);
-                               }
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [filter, namespace, type]);
-
-       React.useEffect(() => {
-               setTechniques(t => [...t].sort(compareTranslation('title', i18n.language)));
-       }, [namespace, i18n.language]);
-
-       if (loading) {
-               return <Loading />;
-       }
-
-       if (error) {
-               return <ErrorMessage error={error} />;
-       }
-
-       if (!techniques || !techniques.length) {
-               return <NotFound />;
-       }
-
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>{i18n.t(`${namespace}.heading`)}</title>
-                       <meta name="description" content={i18n.t(`${namespace}.description`)} />
-               </Helmet>
-               <CanonicalLinks base="/tech" />
-               <Overview
-                       filter={filter}
-                       namespace={namespace}
-                       setFilter={updateFilter}
-                       techniques={techniques}
-                       type={type}
-               />
-       </ErrorBoundary>;
-};
-
-Techniques.propTypes = {
-       namespace: PropTypes.string,
-       type: PropTypes.string,
-};
-
-export default withTranslation()(Techniques);
diff --git a/resources/js/pages/Techniques.jsx b/resources/js/pages/Techniques.jsx
new file mode 100644 (file)
index 0000000..0c5d5b0
--- /dev/null
@@ -0,0 +1,100 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { withTranslation } from 'react-i18next';
+
+import NotFound from './NotFound';
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Loading from '../components/common/Loading';
+import Overview from '../components/techniques/Overview';
+import { compareTranslation } from '../helpers/Technique';
+import i18n from '../i18n';
+
+const Techniques = ({ namespace, type }) => {
+       const [error, setError] = React.useState(null);
+       const [filter, setFilter] = React.useState({});
+       const [loading, setLoading] = React.useState(true);
+       const [techniques, setTechniques] = React.useState([]);
+
+       React.useEffect(() => {
+               const savedFilter = localStorage.getItem(`content.filter.${type}`);
+               if (savedFilter) {
+                       setFilter(JSON.parse(savedFilter));
+               } else {
+                       setFilter(filter => filter ? {} : filter);
+               }
+       }, [type]);
+
+       const updateFilter = React.useCallback(newFilter => {
+               localStorage.setItem(`content.filter.${type}`, JSON.stringify(newFilter));
+               setFilter(newFilter);
+       }, [type]);
+
+       React.useEffect(() => {
+               const ctrl = new AbortController();
+               if (!techniques.length) {
+                       setLoading(true);
+               }
+               axios
+                       .get(`/api/pages/${type}`, {
+                               params: filter,
+                               signal: ctrl.signal
+                       })
+                       .then(response => {
+                               setError(null);
+                               setLoading(false);
+                               setTechniques(response.data.sort(compareTranslation('title', i18n.language)));
+                       })
+                       .catch(error => {
+                               if (!axios.isCancel(error)) {
+                                       setError(error);
+                                       setLoading(false);
+                                       setTechniques([]);
+                               }
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [filter, namespace, type]);
+
+       React.useEffect(() => {
+               setTechniques(t => [...t].sort(compareTranslation('title', i18n.language)));
+       }, [namespace, i18n.language]);
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       if (!techniques || !techniques.length) {
+               return <NotFound />;
+       }
+
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>{i18n.t(`${namespace}.heading`)}</title>
+                       <meta name="description" content={i18n.t(`${namespace}.description`)} />
+               </Helmet>
+               <CanonicalLinks base="/tech" />
+               <Overview
+                       filter={filter}
+                       namespace={namespace}
+                       setFilter={updateFilter}
+                       techniques={techniques}
+                       type={type}
+               />
+       </ErrorBoundary>;
+};
+
+Techniques.propTypes = {
+       namespace: PropTypes.string,
+       type: PropTypes.string,
+};
+
+export default withTranslation()(Techniques);
diff --git a/resources/js/pages/Tournament.js b/resources/js/pages/Tournament.js
deleted file mode 100644 (file)
index a374fe9..0000000
+++ /dev/null
@@ -1,222 +0,0 @@
-import axios from 'axios';
-import React, { useEffect, useState } from 'react';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-import { useParams } from 'react-router-dom';
-import toastr from 'toastr';
-
-import NotFound from './NotFound';
-import CanonicalLinks from '../components/common/CanonicalLinks';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import ErrorMessage from '../components/common/ErrorMessage';
-import Loading from '../components/common/Loading';
-import Dialog from '../components/techniques/Dialog';
-import Detail from '../components/tournament/Detail';
-import {
-       mayEditContent,
-} from '../helpers/permissions';
-import { getTranslation } from '../helpers/Technique';
-import {
-       canLoadMoreRounds,
-       getLastRound,
-       patchApplication,
-       patchParticipant,
-       patchResult,
-       patchRound,
-       patchUser,
-       removeApplication,
-       sortParticipants,
-} from '../helpers/Tournament';
-import { useUser } from '../hooks/user';
-import i18n from '../i18n';
-
-export const Component = () => {
-       const params = useParams();
-       const { id } = params;
-       const { user } = useUser();
-       const { t } = useTranslation();
-
-       const [error, setError] = useState(null);
-       const [loading, setLoading] = useState(true);
-       const [tournament, setTournament] = useState(null);
-
-       const [editContent, setEditContent] = React.useState(null);
-       const [showContentDialog, setShowContentDialog] = React.useState(false);
-
-       useEffect(() => {
-               const ctrl = new AbortController();
-               setLoading(true);
-               axios
-                       .get(`/api/tournaments/${id}`, { signal: ctrl.signal })
-                       .then(response => {
-                               setError(null);
-                               setLoading(false);
-                               setTournament(sortParticipants(response.data));
-                       })
-                       .catch(error => {
-                               setError(error);
-                               setLoading(false);
-                               setTournament(null);
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [id]);
-
-       useEffect(() => {
-               window.Echo.channel(`Tournament.${id}`)
-                       .listen('ApplicationAdded', e => {
-                               if (e.application) {
-                                       setTournament(tournament => patchApplication(tournament, e.application));
-                               }
-                       })
-                       .listen('ApplicationChanged', e => {
-                               if (e.application) {
-                                       setTournament(tournament => patchApplication(tournament, e.application));
-                               }
-                       })
-                       .listen('ApplicationRemoved', e => {
-                               if (e.application_id) {
-                                       setTournament(tournament => removeApplication(tournament, e.application_id));
-                               }
-                       })
-                       .listen('ParticipantChanged', e => {
-                               if (e.participant) {
-                                       setTournament(tournament => patchParticipant(tournament, e.participant));
-                               }
-                       })
-                       .listen('ResultChanged', e => {
-                               if (e.result) {
-                                       setTournament(tournament => patchResult(tournament, e.result));
-                               }
-                       })
-                       .listen('RoundAdded', e => {
-                               if (e.round) {
-                                       setTournament(tournament => ({
-                                               ...tournament,
-                                               rounds: [e.round, ...tournament.rounds],
-                                       }));
-                               }
-                       })
-                       .listen('RoundChanged', e => {
-                               if (e.round) {
-                                       setTournament(tournament => patchRound(tournament, e.round));
-                               }
-                       })
-                       .listen('.RoundDeleted', e => {
-                               if (e.model) {
-                                       setTournament(tournament => ({
-                                               ...tournament,
-                                               rounds: tournament.rounds.filter((r) => r.id !== e.model.id),
-                                       }));
-                               }
-                       })
-                       .listen('TournamentChanged', e => {
-                               if (e.tournament) {
-                                       setTournament(tournament => ({ ...tournament, ...e.tournament }));
-                               }
-                       });
-               return () => {
-                       window.Echo.leave(`Tournament.${id}`);
-               };
-       }, [id]);
-
-       const addRound = React.useCallback(async () => {
-               await axios.post('/api/rounds', { tournament_id: id });
-       }, [id]);
-
-       const moreRounds = React.useCallback(async () => {
-               const last_round = getLastRound(tournament);
-               if (!last_round) return;
-               console.log(last_round);
-               const last_known = last_round.number;
-               const rsp = await axios.get(
-                       `/api/tournaments/${id}/more-rounds`,
-                       { params: { last_known } },
-               );
-               setTournament(tournament => ({
-                       ...tournament,
-                       rounds: [...tournament.rounds, ...rsp.data],
-               }));
-       }, [id, tournament]);
-
-       const saveContent = React.useCallback(async values => {
-               try {
-                       const response = await axios.put(`/api/content/${values.id}`, {
-                               parent_id: event.description_id,
-                               ...values,
-                       });
-                       toastr.success(t('content.saveSuccess'));
-                       setTournament(tournament => ({
-                               ...tournament,
-                               description: response.data,
-                       }));
-                       setShowContentDialog(false);
-               } catch (e) {
-                       toastr.error(t('content.saveError'));
-               }
-       }, [tournament && tournament.description_id]);
-
-       const actions = React.useMemo(() => ({
-               addRound,
-               editContent: mayEditContent(user) ? content => {
-                       setEditContent(content);
-                       setShowContentDialog(true);
-               } : null,
-               moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null,
-       }), [addRound, moreRounds, tournament, user]);
-
-       useEffect(() => {
-               const cb = (e) => {
-                       if (e.user) {
-                               setTournament(tournament => patchUser(tournament, e.user));
-                       }
-               };
-               window.Echo.channel('App.Control')
-                       .listen('UserChanged', cb);
-               return () => {
-                       window.Echo.channel('App.Control')
-                               .stopListening('UserChanged', cb);
-               };
-       }, []);
-
-       if (loading) {
-               return <Loading />;
-       }
-
-       if (error) {
-               return <ErrorMessage error={error} />;
-       }
-
-       if (!tournament) {
-               return <NotFound />;
-       }
-
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>
-                               {(tournament.description
-                                       && getTranslation(tournament.description, 'title', i18n.language))
-                                       || tournament.title}
-                       </title>
-               </Helmet>
-               {tournament.description ? <Helmet>
-                       <meta
-                               name="description"
-                               content={getTranslation(tournament.description, 'short', i18n.language)}
-                       />
-               </Helmet> : null}
-               <CanonicalLinks base={`/tournaments/${tournament.id}`} />
-               <Detail
-                       actions={actions}
-                       tournament={tournament}
-               />
-               <Dialog
-                       content={editContent}
-                       language={i18n.language}
-                       onHide={() => { setShowContentDialog(false); }}
-                       onSubmit={saveContent}
-                       show={showContentDialog}
-               />
-       </ErrorBoundary>;
-};
diff --git a/resources/js/pages/Tournament.jsx b/resources/js/pages/Tournament.jsx
new file mode 100644 (file)
index 0000000..a374fe9
--- /dev/null
@@ -0,0 +1,222 @@
+import axios from 'axios';
+import React, { useEffect, useState } from 'react';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router-dom';
+import toastr from 'toastr';
+
+import NotFound from './NotFound';
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Loading from '../components/common/Loading';
+import Dialog from '../components/techniques/Dialog';
+import Detail from '../components/tournament/Detail';
+import {
+       mayEditContent,
+} from '../helpers/permissions';
+import { getTranslation } from '../helpers/Technique';
+import {
+       canLoadMoreRounds,
+       getLastRound,
+       patchApplication,
+       patchParticipant,
+       patchResult,
+       patchRound,
+       patchUser,
+       removeApplication,
+       sortParticipants,
+} from '../helpers/Tournament';
+import { useUser } from '../hooks/user';
+import i18n from '../i18n';
+
+export const Component = () => {
+       const params = useParams();
+       const { id } = params;
+       const { user } = useUser();
+       const { t } = useTranslation();
+
+       const [error, setError] = useState(null);
+       const [loading, setLoading] = useState(true);
+       const [tournament, setTournament] = useState(null);
+
+       const [editContent, setEditContent] = React.useState(null);
+       const [showContentDialog, setShowContentDialog] = React.useState(false);
+
+       useEffect(() => {
+               const ctrl = new AbortController();
+               setLoading(true);
+               axios
+                       .get(`/api/tournaments/${id}`, { signal: ctrl.signal })
+                       .then(response => {
+                               setError(null);
+                               setLoading(false);
+                               setTournament(sortParticipants(response.data));
+                       })
+                       .catch(error => {
+                               setError(error);
+                               setLoading(false);
+                               setTournament(null);
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [id]);
+
+       useEffect(() => {
+               window.Echo.channel(`Tournament.${id}`)
+                       .listen('ApplicationAdded', e => {
+                               if (e.application) {
+                                       setTournament(tournament => patchApplication(tournament, e.application));
+                               }
+                       })
+                       .listen('ApplicationChanged', e => {
+                               if (e.application) {
+                                       setTournament(tournament => patchApplication(tournament, e.application));
+                               }
+                       })
+                       .listen('ApplicationRemoved', e => {
+                               if (e.application_id) {
+                                       setTournament(tournament => removeApplication(tournament, e.application_id));
+                               }
+                       })
+                       .listen('ParticipantChanged', e => {
+                               if (e.participant) {
+                                       setTournament(tournament => patchParticipant(tournament, e.participant));
+                               }
+                       })
+                       .listen('ResultChanged', e => {
+                               if (e.result) {
+                                       setTournament(tournament => patchResult(tournament, e.result));
+                               }
+                       })
+                       .listen('RoundAdded', e => {
+                               if (e.round) {
+                                       setTournament(tournament => ({
+                                               ...tournament,
+                                               rounds: [e.round, ...tournament.rounds],
+                                       }));
+                               }
+                       })
+                       .listen('RoundChanged', e => {
+                               if (e.round) {
+                                       setTournament(tournament => patchRound(tournament, e.round));
+                               }
+                       })
+                       .listen('.RoundDeleted', e => {
+                               if (e.model) {
+                                       setTournament(tournament => ({
+                                               ...tournament,
+                                               rounds: tournament.rounds.filter((r) => r.id !== e.model.id),
+                                       }));
+                               }
+                       })
+                       .listen('TournamentChanged', e => {
+                               if (e.tournament) {
+                                       setTournament(tournament => ({ ...tournament, ...e.tournament }));
+                               }
+                       });
+               return () => {
+                       window.Echo.leave(`Tournament.${id}`);
+               };
+       }, [id]);
+
+       const addRound = React.useCallback(async () => {
+               await axios.post('/api/rounds', { tournament_id: id });
+       }, [id]);
+
+       const moreRounds = React.useCallback(async () => {
+               const last_round = getLastRound(tournament);
+               if (!last_round) return;
+               console.log(last_round);
+               const last_known = last_round.number;
+               const rsp = await axios.get(
+                       `/api/tournaments/${id}/more-rounds`,
+                       { params: { last_known } },
+               );
+               setTournament(tournament => ({
+                       ...tournament,
+                       rounds: [...tournament.rounds, ...rsp.data],
+               }));
+       }, [id, tournament]);
+
+       const saveContent = React.useCallback(async values => {
+               try {
+                       const response = await axios.put(`/api/content/${values.id}`, {
+                               parent_id: event.description_id,
+                               ...values,
+                       });
+                       toastr.success(t('content.saveSuccess'));
+                       setTournament(tournament => ({
+                               ...tournament,
+                               description: response.data,
+                       }));
+                       setShowContentDialog(false);
+               } catch (e) {
+                       toastr.error(t('content.saveError'));
+               }
+       }, [tournament && tournament.description_id]);
+
+       const actions = React.useMemo(() => ({
+               addRound,
+               editContent: mayEditContent(user) ? content => {
+                       setEditContent(content);
+                       setShowContentDialog(true);
+               } : null,
+               moreRounds: canLoadMoreRounds(tournament) ? moreRounds : null,
+       }), [addRound, moreRounds, tournament, user]);
+
+       useEffect(() => {
+               const cb = (e) => {
+                       if (e.user) {
+                               setTournament(tournament => patchUser(tournament, e.user));
+                       }
+               };
+               window.Echo.channel('App.Control')
+                       .listen('UserChanged', cb);
+               return () => {
+                       window.Echo.channel('App.Control')
+                               .stopListening('UserChanged', cb);
+               };
+       }, []);
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       if (!tournament) {
+               return <NotFound />;
+       }
+
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>
+                               {(tournament.description
+                                       && getTranslation(tournament.description, 'title', i18n.language))
+                                       || tournament.title}
+                       </title>
+               </Helmet>
+               {tournament.description ? <Helmet>
+                       <meta
+                               name="description"
+                               content={getTranslation(tournament.description, 'short', i18n.language)}
+                       />
+               </Helmet> : null}
+               <CanonicalLinks base={`/tournaments/${tournament.id}`} />
+               <Detail
+                       actions={actions}
+                       tournament={tournament}
+               />
+               <Dialog
+                       content={editContent}
+                       language={i18n.language}
+                       onHide={() => { setShowContentDialog(false); }}
+                       onSubmit={saveContent}
+                       show={showContentDialog}
+               />
+       </ErrorBoundary>;
+};
diff --git a/resources/js/pages/Tracker.js b/resources/js/pages/Tracker.js
deleted file mode 100644 (file)
index eccd766..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-import { Helmet } from 'react-helmet';
-
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import Tracker from '../components/tracker';
-import { TrackerProvider } from '../hooks/tracker';
-
-export const Component = () => {
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>Tracker</title>
-               </Helmet>
-               <TrackerProvider>
-                       <Tracker />
-               </TrackerProvider>
-       </ErrorBoundary>;
-};
diff --git a/resources/js/pages/Tracker.jsx b/resources/js/pages/Tracker.jsx
new file mode 100644 (file)
index 0000000..eccd766
--- /dev/null
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Helmet } from 'react-helmet';
+
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import Tracker from '../components/tracker';
+import { TrackerProvider } from '../hooks/tracker';
+
+export const Component = () => {
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>Tracker</title>
+               </Helmet>
+               <TrackerProvider>
+                       <Tracker />
+               </TrackerProvider>
+       </ErrorBoundary>;
+};
diff --git a/resources/js/pages/TwitchBot.js b/resources/js/pages/TwitchBot.js
deleted file mode 100644 (file)
index de13f7f..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-import React from 'react';
-import { Alert, Container } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-
-import Controls from '../components/twitch-bot/Controls';
-import { mayManageTwitchBot } from '../helpers/permissions';
-import { useUser } from '../hooks/user';
-
-export const Component = () => {
-       const { t } = useTranslation();
-       const { user } = useUser();
-
-       return <Container>
-               <h1>{t('twitchBot.heading')}</h1>
-               <Helmet>
-                       <title>{t('twitchBot.heading')}</title>
-               </Helmet>
-               {mayManageTwitchBot(user) ? <>
-                       <h2>{t('twitchBot.controls')}</h2>
-                       <Controls />
-               </> :
-                       <Alert variant="info">
-                               {t('twitchBot.noManagePermission')}
-                       </Alert>
-               }
-       </Container>;
-};
diff --git a/resources/js/pages/TwitchBot.jsx b/resources/js/pages/TwitchBot.jsx
new file mode 100644 (file)
index 0000000..de13f7f
--- /dev/null
@@ -0,0 +1,28 @@
+import React from 'react';
+import { Alert, Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+
+import Controls from '../components/twitch-bot/Controls';
+import { mayManageTwitchBot } from '../helpers/permissions';
+import { useUser } from '../hooks/user';
+
+export const Component = () => {
+       const { t } = useTranslation();
+       const { user } = useUser();
+
+       return <Container>
+               <h1>{t('twitchBot.heading')}</h1>
+               <Helmet>
+                       <title>{t('twitchBot.heading')}</title>
+               </Helmet>
+               {mayManageTwitchBot(user) ? <>
+                       <h2>{t('twitchBot.controls')}</h2>
+                       <Controls />
+               </> :
+                       <Alert variant="info">
+                               {t('twitchBot.noManagePermission')}
+                       </Alert>
+               }
+       </Container>;
+};
diff --git a/resources/js/pages/TwitchLegal.js b/resources/js/pages/TwitchLegal.js
deleted file mode 100644 (file)
index 197a5cb..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-import { Container } from 'react-bootstrap';
-import { Helmet } from 'react-helmet';
-import { useTranslation } from 'react-i18next';
-
-export const Component = () => {
-       const { t } = useTranslation();
-
-       return <Container>
-               <h1>{t('twitchLegal.heading')}</h1>
-               <Helmet>
-                       <title>{t('twitchLegal.heading')}</title>
-               </Helmet>
-               <p>{t('twitchLegal.p1')}</p>
-               <p>{t('twitchLegal.p2')}</p>
-       </Container>;
-};
diff --git a/resources/js/pages/TwitchLegal.jsx b/resources/js/pages/TwitchLegal.jsx
new file mode 100644 (file)
index 0000000..197a5cb
--- /dev/null
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Container } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+
+export const Component = () => {
+       const { t } = useTranslation();
+
+       return <Container>
+               <h1>{t('twitchLegal.heading')}</h1>
+               <Helmet>
+                       <title>{t('twitchLegal.heading')}</title>
+               </Helmet>
+               <p>{t('twitchLegal.p1')}</p>
+               <p>{t('twitchLegal.p2')}</p>
+       </Container>;
+};
diff --git a/resources/js/pages/User.js b/resources/js/pages/User.js
deleted file mode 100644 (file)
index 9e8d22a..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-import axios from 'axios';
-import React, { useEffect, useState } from 'react';
-import { Helmet } from 'react-helmet';
-import { useParams } from 'react-router-dom';
-
-import NotFound from './NotFound';
-import CanonicalLinks from '../components/common/CanonicalLinks';
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import ErrorMessage from '../components/common/ErrorMessage';
-import Loading from '../components/common/Loading';
-import Profile from '../components/users/Profile';
-
-const User = () => {
-       const params = useParams();
-       const { id } = params;
-
-       const [error, setError] = useState(null);
-       const [loading, setLoading] = useState(true);
-       const [user, setUser] = useState(null);
-
-       useEffect(() => {
-               setLoading(true);
-               const ctrl = new AbortController();
-               axios
-                       .get(`/api/users/${id}`, { signal: ctrl.signal })
-                       .then(response => {
-                               setError(null);
-                               setLoading(false);
-                               setUser(response.data);
-                       })
-                       .catch(error => {
-                               setError(error);
-                               setLoading(false);
-                               setUser(null);
-                       });
-               return () => {
-                       ctrl.abort();
-               };
-       }, [id]);
-
-       useEffect(() => {
-               const cb = (e) => {
-                       if (e.user) {
-                               setUser(user => e.user.id === user.id ? { ...user, ...e.user } : user);
-                       }
-               };
-               window.Echo.channel('App.Control')
-                       .listen('UserChanged', cb);
-               return () => {
-                       window.Echo.channel('App.Control')
-                               .stopListening('UserChanged', cb);
-               };
-       }, []);
-
-       if (loading) {
-               return <Loading />;
-       }
-
-       if (error) {
-               return <ErrorMessage error={error} />;
-       }
-
-       if (!user) {
-               return <NotFound />;
-       }
-
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>{user.nickname || user.username}</title>
-               </Helmet>
-               <CanonicalLinks base={`/users/${user.id}`} />
-               <Profile user={user} />
-       </ErrorBoundary>;
-};
-
-export default User;
diff --git a/resources/js/pages/User.jsx b/resources/js/pages/User.jsx
new file mode 100644 (file)
index 0000000..9e8d22a
--- /dev/null
@@ -0,0 +1,76 @@
+import axios from 'axios';
+import React, { useEffect, useState } from 'react';
+import { Helmet } from 'react-helmet';
+import { useParams } from 'react-router-dom';
+
+import NotFound from './NotFound';
+import CanonicalLinks from '../components/common/CanonicalLinks';
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import ErrorMessage from '../components/common/ErrorMessage';
+import Loading from '../components/common/Loading';
+import Profile from '../components/users/Profile';
+
+const User = () => {
+       const params = useParams();
+       const { id } = params;
+
+       const [error, setError] = useState(null);
+       const [loading, setLoading] = useState(true);
+       const [user, setUser] = useState(null);
+
+       useEffect(() => {
+               setLoading(true);
+               const ctrl = new AbortController();
+               axios
+                       .get(`/api/users/${id}`, { signal: ctrl.signal })
+                       .then(response => {
+                               setError(null);
+                               setLoading(false);
+                               setUser(response.data);
+                       })
+                       .catch(error => {
+                               setError(error);
+                               setLoading(false);
+                               setUser(null);
+                       });
+               return () => {
+                       ctrl.abort();
+               };
+       }, [id]);
+
+       useEffect(() => {
+               const cb = (e) => {
+                       if (e.user) {
+                               setUser(user => e.user.id === user.id ? { ...user, ...e.user } : user);
+                       }
+               };
+               window.Echo.channel('App.Control')
+                       .listen('UserChanged', cb);
+               return () => {
+                       window.Echo.channel('App.Control')
+                               .stopListening('UserChanged', cb);
+               };
+       }, []);
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       if (!user) {
+               return <NotFound />;
+       }
+
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>{user.nickname || user.username}</title>
+               </Helmet>
+               <CanonicalLinks base={`/users/${user.id}`} />
+               <Profile user={user} />
+       </ErrorBoundary>;
+};
+
+export default User;
diff --git a/resources/js/pages/ZootrMixedPoolsTracker.js b/resources/js/pages/ZootrMixedPoolsTracker.js
deleted file mode 100644 (file)
index 100af98..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-import { Helmet } from 'react-helmet';
-
-import ErrorBoundary from '../components/common/ErrorBoundary';
-import Tracker from '../components/zootr/MixedPoolsTracker';
-
-export const Component = () => {
-       return <ErrorBoundary>
-               <Helmet>
-                       <title>ZOOTR Mixed Pools Tracker</title>
-               </Helmet>
-               <Tracker />
-       </ErrorBoundary>;
-};
diff --git a/resources/js/pages/ZootrMixedPoolsTracker.jsx b/resources/js/pages/ZootrMixedPoolsTracker.jsx
new file mode 100644 (file)
index 0000000..100af98
--- /dev/null
@@ -0,0 +1,14 @@
+import React from 'react';
+import { Helmet } from 'react-helmet';
+
+import ErrorBoundary from '../components/common/ErrorBoundary';
+import Tracker from '../components/zootr/MixedPoolsTracker';
+
+export const Component = () => {
+       return <ErrorBoundary>
+               <Helmet>
+                       <title>ZOOTR Mixed Pools Tracker</title>
+               </Helmet>
+               <Tracker />
+       </ErrorBoundary>;
+};
diff --git a/resources/js/setup-jest.js b/resources/js/setup-jest.js
deleted file mode 100644 (file)
index 7b0828b..0000000
+++ /dev/null
@@ -1 +0,0 @@
-import '@testing-library/jest-dom';
index 7781b19e6e6e2f6ba7d32b7201bbfa4a4a41ae1e..abd87d068077d8ab49728a44712997e3f9b39a41 100644 (file)
@@ -1,18 +1,18 @@
-@import '~bootstrap/scss/functions';
+@import 'bootstrap/scss/functions';
 
 @import 'variables';
 
-@import "~bootstrap/scss/variables";
-@import "~bootstrap/scss/variables-dark";
+@import 'bootstrap/scss/variables';
+@import 'bootstrap/scss/variables-dark';
 
 $theme-colors: map-merge($theme-colors, $custom-colors);
 
-@import "~bootstrap/scss/maps";
-@import "~bootstrap/scss/mixins";
-@import "~bootstrap/scss/root";
+@import 'bootstrap/scss/maps';
+@import 'bootstrap/scss/mixins';
+@import 'bootstrap/scss/root';
 
-@import "~bootstrap/scss/utilities";
+@import 'bootstrap/scss/utilities';
 
 $utilities: map-merge($utilities, ("font-size": map-merge(map-get($utilities, "font-size"), (responsive: true))));
 
-@import '~bootstrap/scss/bootstrap';
+@import 'bootstrap/scss/bootstrap';
index f1b807942ec01263ec21b8b33e570f67ded25271..a4b4a421023bc3bdca429d6ee6412b58193b19b6 100644 (file)
                <meta property="twitter:image" content="{{ $image }}">
 @endisset
 
-               <script src="{{ mix('js/manifest.js') }}" defer></script>
-               <script src="{{ mix('js/vendor.js') }}" defer></script>
-               <script src="{{ mix('js/index.js') }}" defer></script>
-
-               <link href="{{ mix('css/app.css') }}" rel="stylesheet">
+               @viteReactRefresh
+               @vite(['resources/js/index.jsx'])
        </head>
        <body>
                <div id="react-root" class="title m-b-md">
index 2d86f3f8330672dfe2715c602e890d0c9afeec0b..eecff377417b09730f9bba25cd23a058721e37f6 100644 (file)
@@ -1,6 +1,8 @@
+import { describe, expect, test } from 'vitest';
+
 import {
        parseTime,
-} from 'helpers/Result';
+} from '@/helpers/Result';
 
 describe('parseTime', () => {
        test('null on empty', () => {
index 86233f8916f846ee4262f3213ccfcae24982fd02..7a7b6e24f13663d867b6239f7e2dd397dcca1b75 100644 (file)
@@ -1,6 +1,8 @@
+import { describe, expect, test } from 'vitest';
+
 import {
        getUserName,
-} from 'helpers/User';
+} from '@/helpers/User';
 
 describe('getUserName', () => {
        test('empty on missing user', () => {
index 5bb9ca3fd841a38f4b05c6bff543c84fc6ea3813..f072f49c7145cc7a70e4e0c18f53e03232b12454 100644 (file)
@@ -1,10 +1,12 @@
+import { describe, expect, test } from 'vitest';
+
 import {
        CONFIG,
        applyLogic,
        configureDungeons,
        getLocationStatus,
        makeEmptyState,
-} from 'helpers/tracker';
+} from '@/helpers/tracker';
 
 describe('base reachability', () => {
        const config = {
index 615c385af393df5731d4d4d2cbab3e7b10ae8a0d..7b675e3e19a706e3383c6cebf59453db5a3e3449 100644 (file)
@@ -1,10 +1,12 @@
+import { describe, expect, test } from 'vitest';
+
 import {
        CONFIG,
        applyLogic,
        configureDungeons,
        getLocationStatus,
        makeEmptyState,
-} from 'helpers/tracker';
+} from '@/helpers/tracker';
 
 describe('base reachability', () => {
        const config = { ...CONFIG };
index ceaaaccd254fc3dbb354fae469d8d76d2c3e8001..ed3372c2c126fa0a684d9982a3ecb69760f1f030 100644 (file)
@@ -1,10 +1,12 @@
+import { describe, expect, test } from 'vitest';
+
 import {
        CONFIG,
        configureDungeons,
        getDungeonBoss,
        getDungeonRemainingItems,
        makeEmptyState,
-} from 'helpers/tracker';
+} from '@/helpers/tracker';
 
 describe('default dungeon configuration', () => {
        const dungeons = configureDungeons(CONFIG);
diff --git a/vite.config.js b/vite.config.js
new file mode 100644 (file)
index 0000000..f036e30
--- /dev/null
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite';
+import laravel from 'laravel-vite-plugin';
+import react from '@vitejs/plugin-react';
+import { manualChunksPlugin } from 'vite-plugin-webpackchunkname';
+
+export default defineConfig({
+    plugins: [
+        laravel([
+            'resources/js/index.jsx',
+        ]),
+        react(),
+               manualChunksPlugin(),
+    ],
+       test: {
+               environment: 'jsdom',
+       },
+});
diff --git a/webpack.mix.js b/webpack.mix.js
deleted file mode 100644 (file)
index 60085ab..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-const mix = require('laravel-mix');
-
-/*
- |--------------------------------------------------------------------------
- | Mix Asset Management
- |--------------------------------------------------------------------------
- |
- | Mix provides a clean, fluent API for defining some Webpack build steps
- | for your Laravel application. By default, we are compiling the Sass
- | file for the application as well as bundling up all the JS files.
- |
- */
-
-mix.js('resources/js/index.js', 'public/js')
-       .react()
-       .sass('resources/sass/app.scss', 'public/css')
-       .extract([
-               '@babel/runtime',
-               '@fortawesome/fontawesome-free',
-               '@fortawesome/fontawesome-svg-core',
-               '@fortawesome/free-brands-svg-icons',
-               '@fortawesome/free-solid-svg-icons',
-               '@fortawesome/react-fontawesome',
-               '@popperjs/core',
-               '@restart/hooks',
-               '@restart/ui',
-               'axios',
-               'bootstrap',
-               'call-bind',
-               'classnames',
-               'formik',
-               'history',
-               'i18next',
-               'i18next-browser-languagedetector',
-               'invariant',
-               'laravel-echo',
-               'lodash',
-               'lodash-es',
-               'moment',
-               'numeral',
-               'property-expr',
-               'pusher-js',
-               'qs',
-               'react',
-               'react-bootstrap',
-               'react-dom',
-               'react-fast-compare',
-               'react-i18next',
-               'react-is',
-               'react-lifecycles-compat',
-               'react-resize-detector',
-               'react-router',
-               'react-router-bootstrap',
-               'react-router-dom',
-               'react-smooth',
-               'react-transition-group',
-               'reduce-css-calc',
-               'regenerator-runtime',
-               'resize-observer-polyfill',
-               'scheduler',
-               'side-channel',
-               'tiny-warning',
-               'toastr',
-               'toposort',
-               'uncontrollable',
-               'void-elements',
-               'warning',
-               'yup',
-       ])
-       .sourceMaps(true)
-       .version();
-//if (mix.inProduction()) {
-       mix.webpackConfig({
-               output: {
-                       chunkFilename: 'js/chunks/[name].[chunkhash].js',
-               },
-       });
-//} else {
-//     mix.webpackConfig({
-//             output: {
-//                     asyncChunks: false,
-//             },
-//     });
-//}