From fef1ffc9558ab795f7d2240231f57e8c0fb2b16e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:51:42 +0200 Subject: [PATCH] Design refresh + a bunch of stuff (#2864) Co-authored-by: hfcRed --- .beans.yml | 6 - .gitignore | 9 +- AGENTS.md | 3 +- TOURNAMENT_LFG_PLAN.md | 329 +++ TOURNAMENT_LFG_SPEC.md | 166 ++ app/browser-test-setup.ts | 12 +- app/components/AbilitiesSelector.module.css | 4 +- app/components/Ability.module.css | 7 +- app/components/AddNewButton.module.css | 48 - app/components/AddNewButton.tsx | 23 - app/components/Alert.module.css | 43 + app/components/Alert.tsx | 23 +- app/components/Avatar.module.css | 21 + app/components/Avatar.tsx | 137 +- app/components/BuildCard.module.css | 44 +- app/components/BuildCard.tsx | 32 +- app/components/Catcher.tsx | 4 +- app/components/Chart.module.css | 34 + app/components/Chart.tsx | 25 +- app/components/CopyToClipboardPopover.tsx | 5 +- app/components/CustomThemeSelector.module.css | 103 + app/components/CustomThemeSelector.tsx | 506 ++++ app/components/CustomizedColorsInput.tsx | 357 --- app/components/Divider.module.css | 28 + app/components/Divider.tsx | 7 +- app/components/EventsList.module.css | 8 + app/components/EventsList.tsx | 101 + app/components/FormErrors.module.css | 7 + app/components/FormErrors.tsx | 3 +- app/components/FormMessage.module.css | 17 + app/components/FormMessage.tsx | 5 +- app/components/FriendCodeInput.tsx | 6 +- app/components/FriendCodePopover.tsx | 33 + app/components/GearSelect.tsx | 4 +- app/components/Image.module.css | 8 + app/components/Image.tsx | 7 +- app/components/InfoPopover.module.css | 24 + app/components/InfoPopover.tsx | 12 +- app/components/Input.module.css | 60 + app/components/Input.tsx | 14 +- app/components/Label.module.css | 28 + app/components/Label.tsx | 9 +- app/components/Main.module.css | 24 + app/components/Main.tsx | 62 +- app/components/MapPoolSelector.module.css | 46 +- app/components/MapPoolSelector.tsx | 25 +- app/components/Markdown.tsx | 3 +- app/components/MobileNav.module.css | 391 +++ app/components/MobileNav.tsx | 630 +++++ app/components/NotificationDot.module.css | 33 + app/components/NotificationDot.tsx | 10 + app/components/Pagination.module.css | 117 + app/components/Pagination.tsx | 249 +- app/components/RequiredHiddenInput.module.css | 8 + app/components/RequiredHiddenInput.tsx | 4 +- app/components/Section.module.css | 12 + app/components/Section.tsx | 4 +- app/components/SideNav.module.css | 299 ++ app/components/SideNav.tsx | 214 ++ app/components/StageSelect.module.css | 6 +- app/components/StageSelect.tsx | 2 - app/components/StreamListItems.module.css | 58 + app/components/StreamListItems.tsx | 181 ++ app/components/SubNav.module.css | 90 + app/components/SubNav.tsx | 17 +- app/components/Table.module.css | 38 + app/components/Table.tsx | 6 +- app/components/TierPill.module.css | 11 +- app/components/TimePopover.module.css | 14 + app/components/TimePopover.tsx | 16 +- app/components/WeaponSelect.module.css | 4 - app/components/WeaponSelect.tsx | 4 +- app/components/YouTubeEmbed.module.css | 18 + app/components/YouTubeEmbed.tsx | 7 +- app/components/elements/BottomTexts.tsx | 1 + app/components/elements/Button.module.css | 109 +- app/components/elements/Button.tsx | 28 +- app/components/elements/Calendar.module.css | 80 + app/components/elements/Calendar.tsx | 42 +- app/components/elements/ChipRadio.module.css | 47 + app/components/elements/ChipRadio.tsx | 58 + app/components/elements/DatePicker.module.css | 48 + app/components/elements/DatePicker.tsx | 18 +- app/components/elements/Dialog.module.css | 15 +- app/components/elements/Dialog.tsx | 7 +- app/components/elements/FieldError.tsx | 3 +- app/components/elements/FieldMessage.tsx | 3 +- app/components/elements/Label.module.css | 6 + app/components/elements/Label.tsx | 5 +- app/components/elements/Menu.module.css | 82 +- app/components/elements/Menu.tsx | 36 +- app/components/elements/Popover.module.css | 14 + app/components/elements/Popover.tsx | 8 +- app/components/elements/Select.module.css | 125 +- app/components/elements/Select.tsx | 18 +- app/components/elements/Switch.module.css | 60 + app/components/elements/Switch.tsx | 12 +- app/components/elements/Tabs.module.css | 50 +- app/components/elements/Tabs.tsx | 40 +- .../elements/Toast.browser.test.tsx | 43 + app/components/elements/Toast.module.css | 53 +- app/components/elements/Toast.tsx | 19 +- .../elements/TournamentSearch.module.css | 21 +- app/components/elements/TournamentSearch.tsx | 12 +- app/components/elements/UserSearch.module.css | 19 +- app/components/elements/UserSearch.tsx | 57 +- app/components/fuse/Fuse.tsx | 56 + app/components/icons/Alert.tsx | 16 - app/components/icons/ArchiveBox.tsx | 18 - app/components/icons/ArrowDownOnSquare.tsx | 12 - app/components/icons/ArrowLeft.tsx | 25 - app/components/icons/ArrowLongLeft.tsx | 25 - app/components/icons/ArrowUpOnSquare.tsx | 12 - app/components/icons/Beaker.tsx | 25 - app/components/icons/BeakerFilled.tsx | 16 - app/components/icons/Bell.tsx | 16 - app/components/icons/Calendar.tsx | 25 - app/components/icons/ChartBar.tsx | 27 - app/components/icons/Checkmark.tsx | 29 - app/components/icons/ChevronDown.tsx | 17 - app/components/icons/ChevronUp.tsx | 17 - app/components/icons/ChevronUpDown.tsx | 17 - app/components/icons/Clipboard.tsx | 18 - app/components/icons/Clock.tsx | 16 - app/components/icons/Cross.tsx | 23 - app/components/icons/Download.tsx | 18 - app/components/icons/Edit.tsx | 24 - app/components/icons/Error.tsx | 16 - app/components/icons/Eye.tsx | 23 - app/components/icons/EyeSlash.tsx | 18 - app/components/icons/Filter.tsx | 18 - app/components/icons/FilterFilled.tsx | 16 - app/components/icons/Fire.tsx | 32 - app/components/icons/Hamburger.tsx | 18 - app/components/icons/Heart.tsx | 12 - app/components/icons/Key.tsx | 16 - app/components/icons/Link.tsx | 16 - app/components/icons/Lock.tsx | 16 - app/components/icons/LogIn.tsx | 26 - app/components/icons/LogOut.tsx | 18 - app/components/icons/Map.tsx | 16 - app/components/icons/MegaphoneIcon.tsx | 12 - app/components/icons/Microphone.tsx | 19 - app/components/icons/MicrophoneFilled.tsx | 13 - app/components/icons/Minus.tsx | 18 - app/components/icons/Pick.tsx | 18 - app/components/icons/Plus.tsx | 18 - app/components/icons/Puzzle.tsx | 12 - app/components/icons/Refresh.tsx | 18 - app/components/icons/RefreshArrows.tsx | 18 - app/components/icons/Scale.tsx | 16 - app/components/icons/Search.tsx | 18 - app/components/icons/Sort.tsx | 18 - app/components/icons/Speaker.tsx | 19 - app/components/icons/SpeakerFilled.tsx | 13 - app/components/icons/SpeakerX.tsx | 19 - app/components/icons/SpeechBubble.tsx | 18 - app/components/icons/SpeechBubbleFilled.tsx | 16 - app/components/icons/Star.tsx | 18 - app/components/icons/StarFilled.tsx | 16 - app/components/icons/Trash.tsx | 18 - app/components/icons/Trophy.tsx | 23 - app/components/icons/Unlink.tsx | 18 - app/components/icons/Unlock.tsx | 12 - app/components/icons/User.tsx | 16 - app/components/icons/Users.tsx | 12 - app/components/layout/AnythingAdder.tsx | 17 +- app/components/layout/ChatSidebar.module.css | 243 ++ app/components/layout/ChatSidebar.tsx | 245 ++ app/components/layout/Footer.module.css | 121 + app/components/layout/Footer.tsx | 36 +- app/components/layout/GlobalSearch.module.css | 273 ++ app/components/layout/GlobalSearch.tsx | 442 +++ .../layout/LogInButtonContainer.tsx | 5 +- app/components/layout/NavDialog.tsx | 121 - .../layout/NotificationPopover.module.css | 72 +- app/components/layout/NotificationPopover.tsx | 70 +- app/components/layout/TopNavMenus.module.css | 62 + app/components/layout/TopNavMenus.tsx | 110 + .../layout/TopRightButtons.module.css | 105 + app/components/layout/TopRightButtons.tsx | 134 +- app/components/layout/UserItem.module.css | 8 + app/components/layout/UserItem.tsx | 35 - app/components/layout/WeaponSearch.tsx | 277 ++ app/components/layout/index.module.css | 279 +- app/components/layout/index.tsx | 634 ++++- app/components/layout/nav-items.ts | 10 - app/components/ramp/Ramp.tsx | 50 - app/db/seed/index.ts | 338 ++- app/db/tables.ts | 97 +- app/entry.client.tsx | 1 + app/entry.server.tsx | 8 + app/features/admin/admin-constants.ts | 3 + app/features/admin/loaders/admin.server.ts | 17 +- app/features/admin/routes/admin.tsx | 72 +- app/features/admin/routes/generate-images.tsx | 4 +- app/features/api-private/routes/seed.ts | 16 +- app/features/api/routes/api.tsx | 11 +- .../art/components/ArtGrid.module.css | 44 + app/features/art/components/ArtGrid.tsx | 30 +- app/features/art/routes/art.new.tsx | 7 +- app/features/art/routes/art.tsx | 43 +- app/features/articles/routes/a.$slug.tsx | 34 +- app/features/articles/routes/a.module.css | 12 + app/features/articles/routes/a.tsx | 9 +- .../AssociationRepository.server.ts | 18 +- .../associations/associations-constants.ts | 2 +- .../associations/core/Association.test.ts | 57 + app/features/associations/core/Association.ts | 10 + .../associations/routes/associations.tsx | 25 +- app/features/auth/core/routes.server.ts | 29 +- app/features/auth/core/user.server.ts | 12 +- app/features/badges/badges.module.css | 108 + .../badges/components/BadgeDisplay.module.css | 29 +- .../badges/components/BadgeDisplay.tsx | 4 +- .../badges/routes/badges.$id.edit.tsx | 54 +- app/features/badges/routes/badges.$id.tsx | 11 +- app/features/badges/routes/badges.tsx | 20 +- app/features/build-analyzer/analyzer.css | 329 --- .../components/PerInkTankGrid.tsx | 33 +- .../build-analyzer/routes/analyzer.module.css | 311 +++ .../build-analyzer/routes/analyzer.tsx | 133 +- .../routes/builds.$slug.popular.tsx | 5 - .../builds.$slug.stats.module.css} | 16 +- .../build-stats/routes/builds.$slug.stats.tsx | 23 +- .../components/FilterSection.module.css | 9 +- .../builds/components/FilterSection.tsx | 4 +- .../builds/routes/builds.$slug.module.css | 2 +- app/features/builds/routes/builds.$slug.tsx | 24 +- app/features/builds/routes/builds.module.css | 30 +- app/features/builds/routes/builds.tsx | 9 - .../calendar/calendar-event.module.css} | 36 +- app/features/calendar/calendar-new.module.css | 18 + .../calendar/calendar-schemas.server.ts | 5 +- .../BracketProgressionSelector.module.css | 11 + .../components/BracketProgressionSelector.tsx | 10 +- .../calendar/components/FiltersDialog.tsx | 4 +- .../calendar/components/Tags.module.css | 43 + app/features/calendar/components/Tags.tsx | 20 +- .../components/TagsFormField.module.css | 34 - .../components/TournamentCard.module.css | 82 +- .../calendar/components/TournamentCard.tsx | 13 +- .../calendar/loaders/events.server.ts | 38 + .../routes/calendar.$id.report-winners.tsx | 1 - app/features/calendar/routes/calendar.$id.tsx | 19 +- .../calendar/routes/calendar.module.css | 59 +- app/features/calendar/routes/calendar.new.tsx | 102 +- app/features/calendar/routes/calendar.tsx | 32 +- .../calendar/routes/events.module.css | 8 + app/features/calendar/routes/events.tsx | 86 + app/features/chat/ChatProvider.tsx | 691 +++++ app/features/chat/ChatSystemMessage.server.ts | 117 +- app/features/chat/chat-hooks.ts | 219 +- app/features/chat/chat-provider-types.ts | 55 + app/features/chat/chat-types.ts | 4 +- app/features/chat/chat-utils.test.ts | 91 + app/features/chat/chat-utils.ts | 30 + app/features/chat/components/Chat.module.css | 165 ++ app/features/chat/components/Chat.tsx | 143 +- app/features/chat/routes/api.chat-users.ts | 26 + app/features/chat/useChatContext.ts | 8 + .../components/DamageComboBar.module.css | 125 +- .../components/RangeVisualization.module.css | 36 +- .../components/RangeVisualization.tsx | 14 +- .../components/SelectedWeapons.module.css | 34 +- .../components/WeaponCategories.module.css | 18 +- .../components/WeaponGrid.module.css | 24 +- .../components-showcase.module.css | 25 + .../form-examples-schema.ts | 157 ++ .../components-showcase/routes/components.tsx | 2418 +++++++++++++++++ app/features/core/streams/streams.server.ts | 71 + .../friends/FriendRepository.server.test.ts | 465 ++++ .../friends/FriendRepository.server.ts | 397 +++ .../friends/actions/friends.server.ts | 90 + .../friends/components/FriendMenu.tsx | 141 + app/features/friends/friends-constants.ts | 5 + .../friends/friends-schemas.server.ts | 58 + app/features/friends/friends-schemas.ts | 28 + app/features/friends/friends-utils.server.ts | 28 + .../friends/loaders/friends.server.ts | 118 + .../friends/routes/friends.module.css | 23 + app/features/friends/routes/friends.tsx | 224 ++ .../components/SplatoonRotations.module.css | 109 + .../components/SplatoonRotations.tsx | 305 +++ .../core/ShowcaseTournaments.server.ts | 7 +- .../front-page/loaders/index.server.ts | 13 +- app/features/front-page/routes/index.tsx | 161 +- .../img-upload/routes/upload.admin.tsx | 4 +- app/features/img-upload/routes/upload.tsx | 1 - app/features/img-upload/upload-constants.ts | 2 +- app/features/info/routes/faq.module.css | 8 +- .../support.module.css} | 16 +- app/features/info/routes/support.tsx | 21 +- .../layout/core/sidenav-session.server.ts | 33 + app/features/layout/routes/sidenav.ts | 18 + .../components/TopTenPlayer.module.css | 24 + .../leaderboards/components/TopTenPlayer.tsx | 9 +- .../leaderboards/routes/leaderboards.tsx | 70 +- app/features/lfg/actions/lfg.new.server.ts | 55 +- .../lfg/components/LFGAddFilterButton.tsx | 4 +- app/features/lfg/components/LFGFilters.tsx | 4 +- .../lfg/components/LFGPost.module.css | 44 +- app/features/lfg/components/LFGPost.tsx | 11 +- app/features/lfg/lfg-schemas.ts | 39 + app/features/lfg/routes/lfg.module.css | 5 + app/features/lfg/routes/lfg.new.tsx | 314 +-- app/features/lfg/routes/lfg.tsx | 6 +- app/features/links/routes/links.module.css | 11 + app/features/links/routes/links.tsx | 5 +- .../LiveStreamRepository.server.ts | 23 + .../map-list-generator/routes/maps.module.css | 25 +- .../map-list-generator/routes/maps.tsx | 6 +- .../map-planner/components/Planner.module.css | 192 ++ .../map-planner/components/Planner.tsx | 143 +- app/features/map-planner/plans-global.css | 106 + app/features/map-planner/plans.css | 216 -- app/features/map-planner/routes/plans.tsx | 2 +- .../components/NotificationList.module.css | 35 +- .../notifications/notifications-types.ts | 3 +- .../notifications/notifications-utils.ts | 6 + .../routes/notifications.module.css | 3 +- .../notifications/routes/notifications.tsx | 4 +- .../object-damage-calculator/calculator.css | 163 -- .../object-damage-calculator.module.css | 137 + .../routes/object-damage-calculator.tsx | 75 +- app/features/plus-suggestions/plus.module.css | 126 + .../routes/plus.suggestions.tsx | 36 +- app/features/plus-suggestions/routes/plus.tsx | 12 +- .../plus-voting-results.module.css | 74 + .../routes/plus.voting.results.tsx | 21 +- .../plus-voting/routes/plus.voting.tsx | 23 +- .../scrims/ScrimPostRepository.server.ts | 71 + .../scrims/actions/scrims.new.server.ts | 14 +- app/features/scrims/actions/scrims.server.ts | 23 +- .../scrims/components/ScrimCard.module.css | 55 +- app/features/scrims/components/ScrimCard.tsx | 32 +- .../scrims/components/ScrimFiltersDialog.tsx | 4 +- .../scrims/loaders/scrims.$id.server.ts | 12 +- app/features/scrims/loaders/scrims.server.ts | 1 + .../scrims/routes/scrims.$id.module.css | 44 +- app/features/scrims/routes/scrims.$id.tsx | 39 +- app/features/scrims/routes/scrims.module.css | 68 +- .../scrims/routes/scrims.new.module.css | 1 - app/features/scrims/routes/scrims.new.tsx | 4 +- app/features/scrims/routes/scrims.tsx | 25 +- app/features/search/routes/search.ts | 103 + app/features/search/search-schemas.ts | 8 + app/features/search/search-types.ts | 7 + .../sendouq-match/SQMatchRepository.server.ts | 2 - .../loaders/q.match.$id.server.ts | 12 + .../routes/q.match.$id.module.css | 45 +- .../sendouq-match/routes/q.match.$id.tsx | 148 +- .../QSettingsRepository.server.ts | 24 - .../actions/q.settings.server.ts | 7 - .../components/ModeMapPoolPicker.module.css | 107 + .../components/ModeMapPoolPicker.tsx | 39 +- .../PreferenceRadioGroup.module.css | 35 + .../components/PreferenceRadioGroup.tsx | 34 +- .../loaders/q.settings.server.ts | 1 - .../q-settings-schemas.server.ts | 6 +- .../sendouq-settings/q-settings-schemas.ts | 2 +- app/features/sendouq-settings/q-settings.css | 73 - .../routes/q.settings.module.css | 36 + .../sendouq-settings/routes/q.settings.tsx | 123 +- .../QStreamsRepository.server.ts | 4 + .../sendouq-streams/core/streams.server.ts | 116 +- .../routes/q.streams.module.css | 12 +- .../sendouq-streams/routes/q.streams.tsx | 4 +- .../sendouq/SQGroupRepository.server.ts | 60 +- .../sendouq/actions/q.looking.server.ts | 46 +- .../sendouq/actions/q.preparing.server.ts | 13 +- app/features/sendouq/actions/q.server.ts | 18 +- .../components/GroupCard.browser.test.tsx | 4 +- .../sendouq/components/GroupCard.module.css | 105 +- app/features/sendouq/components/GroupCard.tsx | 56 +- .../sendouq/components/MemberAdder.module.css | 4 - .../sendouq/components/MemberAdder.tsx | 56 +- .../sendouq/loaders/q.looking.server.ts | 2 + app/features/sendouq/q-constants.ts | 3 + app/features/sendouq/q-schemas.server.ts | 5 +- app/features/sendouq/q-utils.server.ts | 17 + .../{trusters.ts => friends-for-adding.ts} | 4 +- app/features/sendouq/routes/q.info.module.css | 14 +- .../sendouq/routes/q.looking.module.css | 21 +- app/features/sendouq/routes/q.looking.tsx | 119 +- app/features/sendouq/routes/q.module.css | 30 +- app/features/sendouq/routes/q.tsx | 45 +- .../settings/actions/settings.server.ts | 16 +- .../settings/loaders/settings.server.ts | 1 + .../settings/routes/settings.global.css | 20 + .../settings/routes/settings.module.css | 4 + app/features/settings/routes/settings.tsx | 83 +- app/features/settings/routes/wave.svg | 8 + app/features/settings/settings-schemas.ts | 9 +- .../sidebar/core/StreamRanking.test.ts | 55 + app/features/sidebar/core/StreamRanking.ts | 62 + app/features/sidebar/core/sidebar.server.ts | 391 +++ .../SplatoonRotationRepository.server.ts | 44 + .../splatoon-rotations.server.ts | 136 + app/features/team/TeamRepository.server.ts | 49 +- .../actions/t.$customUrl.edit.server.test.ts | 46 +- .../team/actions/t.$customUrl.edit.server.ts | 34 +- ...{t.server.test.ts => t.new.server.test.ts} | 2 +- .../actions/{t.server.ts => t.new.server.ts} | 0 .../team/components/TeamGoBackButton.tsx | 4 +- .../team/components/TeamResultsTable.tsx | 4 +- .../team/loaders/t.$customUrl.edit.server.ts | 8 +- .../team/loaders/t.$customUrl.server.ts | 2 +- app/features/team/loaders/t.new.server.ts | 13 + app/features/team/loaders/t.server.ts | 63 - .../team/routes/t.$customUrl.edit.test.ts | 2 +- .../team/routes/t.$customUrl.edit.tsx | 68 +- .../team/routes/t.$customUrl.index.tsx | 44 +- .../team/routes/t.$customUrl.join.tsx | 5 +- .../team/routes/t.$customUrl.roster.tsx | 24 +- app/features/team/routes/t.$customUrl.test.ts | 2 +- app/features/team/routes/t.$customUrl.tsx | 27 +- app/features/team/routes/t.new.tsx | 60 + app/features/team/routes/t.tsx | 205 +- app/features/team/team-constants.ts | 2 - app/features/team/team-schemas.server.ts | 14 +- app/features/team/team.css | 376 --- app/features/team/team.module.css | 312 +++ app/features/theme/core/provider.tsx | 163 +- ...sion.server.ts => theme-session.server.ts} | 0 app/features/theme/routes/theme.ts | 2 +- .../components/ItemPool.module.css | 2 +- .../components/TierListItemImage.module.css | 2 +- .../components/TierRow.module.css | 50 +- .../tier-list-maker/components/TierRow.tsx | 13 +- .../routes/tier-list-maker.module.css | 20 +- .../routes/tier-list-maker.tsx | 16 +- .../tier-list-maker-constants.ts | 12 +- .../top-search/components/Placements.tsx | 17 +- .../top-search/routes/xsearch.player.$id.tsx | 6 +- app/features/top-search/routes/xsearch.tsx | 2 - app/features/top-search/top-search.css | 95 - app/features/top-search/top-search.module.css | 96 + .../to.$id.brackets.finalize.server.ts | 3 + .../Bracket/Bracket.browser.test.tsx | 19 +- .../components/Bracket/Elimination.tsx | 91 +- .../components/Bracket/Match.tsx | 130 +- .../components/Bracket/PlacementsTable.tsx | 29 +- .../components/Bracket/RoundHeader.tsx | 16 +- .../components/Bracket/RoundRobin.tsx | 7 +- .../components/Bracket/Swiss.tsx | 6 +- .../components/Bracket/bracket.css | 196 -- .../components/Bracket/bracket.module.css | 320 +++ .../components/Bracket/index.tsx | 6 +- .../BracketMapListDialog.module.css | 52 +- .../components/BracketMapListDialog.tsx | 30 +- .../components/CastInfo.tsx | 21 +- .../components/DeadlineInfoPopover.tsx | 23 +- .../components/MatchActions.tsx | 24 +- .../components/MatchActionsBanPicker.tsx | 7 +- .../components/MatchRosters.tsx | 52 +- .../components/MatchTimer.module.css | 12 +- .../OrganizerMatchMapListDialog.tsx | 2 +- .../components/StartedMatch.tsx | 236 +- .../components/TeamRosterInputs.tsx | 54 +- .../components/TournamentTeamActions.tsx | 11 +- .../core/RunningTournaments.server.ts | 31 + .../core/RunningTournaments.test.ts | 111 + .../core/Tournament.server.ts | 38 +- .../tournament-bracket/core/Tournament.ts | 1 + .../core/summarizer.test.ts | 1 + .../tournament-bracket/core/tests/mocks-li.ts | 450 ++- .../core/tests/mocks-sos.ts | 283 +- .../core/tests/mocks-zones-weekly.ts | 44 +- .../tournament-bracket/core/tests/mocks.ts | 507 +++- .../core/tests/test-utils.ts | 2 - .../loaders/to.$id.matches.$mid.server.ts | 49 +- .../queries/findMatchById.server.ts | 2 - .../routes/to.$id.brackets.finalize.tsx | 1 - .../routes/to.$id.brackets.tsx | 227 +- .../routes/to.$id.divisions.tsx | 4 +- .../routes/to.$id.matches.$mid.test.ts | 9 +- .../routes/to.$id.matches.$mid.tsx | 92 +- .../tournament-bracket/tournament-bracket.css | 601 ---- .../tournament-bracket.module.css | 563 ++++ .../TournamentLFGRepository.server.test.ts | 554 ++++ .../TournamentLFGRepository.server.ts | 411 +++ .../actions/to.$id.looking.server.ts | 268 ++ .../components/LFGGroupCard.module.css | 71 + .../components/LFGGroupCard.tsx | 408 +++ .../loaders/to.$id.looking.server.ts | 226 ++ .../routes/to.$id.looking.module.css | 119 + .../tournament-lfg/routes/to.$id.looking.tsx | 464 ++++ .../tournament-lfg/tournament-lfg-schemas.ts | 63 + .../tournament-lfg/tournament-lfg-utils.ts | 18 + ...TournamentOrganizationRepository.server.ts | 28 + .../components/BannedPlayersList.module.css | 6 +- .../components/EventCalendar.tsx | 21 +- .../components/SocialLinksList.tsx | 20 +- .../routes/org.$slug.tsx | 38 +- .../tournament-organization.css | 196 -- .../tournament-organization.module.css | 196 ++ .../TournamentSubRepository.server.ts | 164 -- .../actions/to.$id.subs.new.server.ts | 54 - .../actions/to.$id.subs.server.ts | 41 - .../loaders/to.$id.subs.new.server.ts | 29 - .../loaders/to.$id.subs.server.ts | 21 +- .../queries/deleteSub.server.ts | 15 - .../routes/to.$id.subs.module.css | 109 - .../routes/to.$id.subs.new.module.css | 9 - .../routes/to.$id.subs.new.tsx | 296 -- .../tournament-subs/routes/to.$id.subs.tsx | 198 +- .../tournament-subs-constants.ts | 4 - .../tournament-subs-schemas.server.ts | 27 - .../SavedCalendarEventRepository.server.ts | 126 + .../tournament/TournamentRepository.server.ts | 63 +- .../TournamentTeamRepository.server.ts | 1 + .../tournament/actions/to.$id.admin.server.ts | 11 +- .../tournament/actions/to.$id.join.server.ts | 17 +- .../actions/to.$id.register.server.ts | 50 +- .../tournament/components/TeamWithRoster.tsx | 42 +- .../components/TournamentStream.tsx | 11 +- .../loaders/to.$id.register.server.ts | 10 +- .../tournament/queries/giveTrust.server.ts | 23 - .../queries/joinLeaveTeam.server.ts | 7 +- .../tournament/routes/to.$id.admin.tsx | 34 +- .../tournament/routes/to.$id.join.tsx | 15 +- .../tournament/routes/to.$id.register.tsx | 267 +- .../tournament/routes/to.$id.results.tsx | 17 +- .../tournament/routes/to.$id.seeds.module.css | 79 +- .../tournament/routes/to.$id.teams.$tid.tsx | 69 +- app/features/tournament/routes/to.$id.tsx | 52 +- .../tournament/tournament-constants.ts | 1 + .../tournament/tournament-schemas.server.ts | 10 +- app/features/tournament/tournament.css | 768 ------ app/features/tournament/tournament.module.css | 490 ++++ .../user-page/UserRepository.server.ts | 59 +- .../actions/u.$identifier.edit.server.ts | 3 - .../components/MutualFriends.module.css | 44 + .../user-page/components/MutualFriends.tsx | 67 + .../components/ParticipationPill.module.css | 10 +- .../components/SubPageHeader.module.css | 14 +- .../user-page/components/SubPageHeader.tsx | 4 +- .../components/UserPageIconNav.module.css | 22 +- .../user-page/components/UserResultsTable.tsx | 7 +- .../user-page/components/Widget.module.css | 86 +- app/features/user-page/components/Widget.tsx | 4 +- .../components/WidgetSettingsForm.tsx | 1 + .../loaders/u.$identifier.edit.server.ts | 5 +- .../user-page/loaders/u.$identifier.server.ts | 13 +- .../routes/u.$identifier.admin.module.css | 11 - .../user-page/routes/u.$identifier.admin.tsx | 7 +- .../user-page/routes/u.$identifier.art.tsx | 10 +- .../routes/u.$identifier.builds.new.tsx | 12 +- .../user-page/routes/u.$identifier.builds.tsx | 50 +- .../u.$identifier.edit-widgets.module.css | 58 +- .../routes/u.$identifier.edit-widgets.tsx | 2 +- .../routes/u.$identifier.edit.test.ts | 32 +- .../user-page/routes/u.$identifier.edit.tsx | 33 +- .../user-page/routes/u.$identifier.index.tsx | 157 +- .../user-page/routes/u.$identifier.module.css | 11 +- .../u.$identifier.results.highlights.tsx | 10 +- .../routes/u.$identifier.seasons.tsx | 140 +- .../user-page/routes/u.$identifier.tsx | 29 +- .../user-page/routes/u.$identifier.vods.tsx | 10 +- app/features/user-page/user-page-constants.ts | 12 - app/features/user-page/user-page-schemas.ts | 17 - app/features/user-page/user-page.module.css | 399 +++ app/features/user-search/loaders/u.server.ts | 40 - app/features/user-search/routes/u.tsx | 132 +- .../vods/components/VodListing.module.css | 12 +- app/features/vods/routes/vods.$id.module.css | 5 +- app/features/vods/routes/vods.$id.tsx | 7 +- app/features/vods/routes/vods.new.module.css | 9 - app/features/vods/routes/vods.new.tsx | 2 +- app/features/vods/routes/vods.tsx | 8 +- app/features/vods/vods-schemas.ts | 2 +- app/form/FormField.tsx | 1 + app/form/SendouForm.browser.test.tsx | 9 +- app/form/SendouForm.module.css | 4 +- app/form/SendouForm.tsx | 36 +- app/form/fields.ts | 1 + app/form/fields/ArrayFormField.module.css | 13 +- app/form/fields/ArrayFormField.tsx | 10 +- app/form/fields/FormFieldWrapper.module.css | 4 +- app/form/fields/InputFormField.tsx | 1 + .../fields/WeaponPoolFormField.module.css | 22 +- app/form/fields/WeaponPoolFormField.tsx | 16 +- app/hooks/swr.ts | 16 +- app/hooks/useMainContentWidth.ts | 66 + app/hooks/useTimeFormat.ts | 34 +- app/hooks/useWindowSize.ts | 31 - app/modules/i18n/resources.server.ts | 32 + app/modules/permissions/mapper.server.ts | 6 +- app/modules/permissions/types.ts | 1 + app/modules/permissions/utils.ts | 8 +- app/modules/twitch/streams.ts | 3 - app/root.tsx | 190 +- app/routes.ts | 19 +- app/routines/deleteOldTrusts.ts | 11 - app/routines/list.server.ts | 6 +- app/routines/syncSplatoonRotations.ts | 15 + app/styles/badges.css | 127 - app/styles/calendar-new.css | 30 - app/styles/common.css | 1989 ++++---------- app/styles/elements.css | 209 -- app/styles/flags.css | 1566 +++++------ app/styles/front.module.css | 156 +- app/styles/layout.css | 415 --- app/styles/normalize.css | 217 ++ app/styles/plus-history.css | 75 - app/styles/plus.css | 145 - app/styles/reset.css | 29 - app/styles/u.$identifier.module.css | 15 - app/styles/u.css | 469 ---- app/styles/utils.css | 994 ++++--- app/styles/vars.css | 437 +-- app/utils/i18n.ts | 1 + app/utils/kysely.server.ts | 13 +- app/utils/logger.ts | 4 + app/utils/oklch-gamut.ts | 339 +++ app/utils/remix.server.ts | 1 - app/utils/team-name-data.ts | 2055 ++++++++++++++ app/utils/team-name.ts | 12 + app/utils/urls.ts | 12 +- app/utils/zod.ts | 142 +- db-test.sqlite3 | Bin 1130496 -> 1208320 bytes docs/dev/database-relations.md | 25 + docs/styles.md | 59 + e2e/associations.spec.ts | 2 +- e2e/ban.spec.ts | 3 - e2e/events.spec.ts | 33 + e2e/friends.spec.ts | 43 + e2e/global-search.spec.ts | 45 + e2e/lfg.spec.ts | 25 +- e2e/navigation.spec.ts | 150 + e2e/org.spec.ts | 19 +- e2e/scrims.spec.ts | 2 +- e2e/seeds/db-seed-DEFAULT.sqlite3 | Bin 6922240 -> 7036928 bytes e2e/seeds/db-seed-NO_SCRIMS.sqlite3 | Bin 6914048 -> 7020544 bytes e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 | Bin 6909952 -> 7028736 bytes e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 | Bin 6864896 -> 6983680 bytes e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 | Bin 6914048 -> 7012352 bytes e2e/seeds/db-seed-REG_OPEN.sqlite3 | Bin 6893568 -> 7000064 bytes e2e/seeds/db-seed-SMALL_SOS.sqlite3 | Bin 6909952 -> 7016448 bytes e2e/sendouq.spec.ts | 12 +- e2e/settings.spec.ts | 13 +- e2e/team.spec.ts | 58 +- e2e/tournament-bracket.spec.ts | 28 +- e2e/tournament-staff.spec.ts | 1 - e2e/tournament-streams.spec.ts | 3 +- e2e/user-page.spec.ts | 63 +- knip.json | 19 - knip.ts | 35 + locales/da/calendar.json | 10 +- locales/da/common.json | 53 +- locales/da/forms.json | 12 +- locales/da/friends.json | 19 + locales/da/front.json | 34 +- locales/da/lfg.json | 2 +- locales/da/q.json | 14 +- locales/da/scrims.json | 1 + locales/da/team.json | 1 + locales/da/tournament.json | 10 +- locales/da/user.json | 5 +- locales/de/calendar.json | 10 +- locales/de/common.json | 53 +- locales/de/forms.json | 12 +- locales/de/friends.json | 19 + locales/de/front.json | 34 +- locales/de/q.json | 14 +- locales/de/scrims.json | 1 + locales/de/team.json | 1 + locales/de/tournament.json | 10 +- locales/de/user.json | 5 +- locales/en/calendar.json | 10 +- locales/en/common.json | 65 +- locales/en/forms.json | 14 +- locales/en/friends.json | 19 + locales/en/front.json | 34 +- locales/en/q.json | 14 +- locales/en/scrims.json | 1 + locales/en/team.json | 1 + locales/en/tournament.json | 10 +- locales/en/user.json | 5 +- locales/es-ES/calendar.json | 10 +- locales/es-ES/common.json | 53 +- locales/es-ES/forms.json | 45 +- locales/es-ES/friends.json | 19 + locales/es-ES/front.json | 34 +- locales/es-ES/lfg.json | 2 +- locales/es-ES/q.json | 14 +- locales/es-ES/scrims.json | 1 + locales/es-ES/team.json | 1 + locales/es-ES/tournament.json | 10 +- locales/es-ES/user.json | 37 +- locales/es-US/calendar.json | 10 +- locales/es-US/common.json | 53 +- locales/es-US/forms.json | 12 +- locales/es-US/friends.json | 19 + locales/es-US/front.json | 34 +- locales/es-US/lfg.json | 2 +- locales/es-US/q.json | 14 +- locales/es-US/scrims.json | 1 + locales/es-US/team.json | 1 + locales/es-US/tournament.json | 10 +- locales/es-US/user.json | 6 +- locales/fr-CA/calendar.json | 10 +- locales/fr-CA/common.json | 53 +- locales/fr-CA/forms.json | 12 +- locales/fr-CA/friends.json | 19 + locales/fr-CA/front.json | 34 +- locales/fr-CA/q.json | 14 +- locales/fr-CA/scrims.json | 1 + locales/fr-CA/team.json | 1 + locales/fr-CA/tournament.json | 10 +- locales/fr-CA/user.json | 6 +- locales/fr-EU/calendar.json | 10 +- locales/fr-EU/common.json | 53 +- locales/fr-EU/forms.json | 12 +- locales/fr-EU/friends.json | 19 + locales/fr-EU/front.json | 37 +- locales/fr-EU/lfg.json | 2 +- locales/fr-EU/q.json | 14 +- locales/fr-EU/scrims.json | 1 + locales/fr-EU/team.json | 1 + locales/fr-EU/tournament.json | 10 +- locales/fr-EU/user.json | 6 +- locales/he/calendar.json | 10 +- locales/he/common.json | 53 +- locales/he/forms.json | 12 +- locales/he/friends.json | 19 + locales/he/front.json | 34 +- locales/he/q.json | 14 +- locales/he/scrims.json | 1 + locales/he/team.json | 1 + locales/he/tournament.json | 10 +- locales/he/user.json | 6 +- locales/it/calendar.json | 10 +- locales/it/common.json | 53 +- locales/it/forms.json | 12 +- locales/it/friends.json | 19 + locales/it/front.json | 37 +- locales/it/lfg.json | 2 +- locales/it/q.json | 14 +- locales/it/scrims.json | 1 + locales/it/team.json | 1 + locales/it/tournament.json | 10 +- locales/it/user.json | 6 +- locales/ja/calendar.json | 10 +- locales/ja/common.json | 53 +- locales/ja/forms.json | 12 +- locales/ja/friends.json | 19 + locales/ja/front.json | 34 +- locales/ja/lfg.json | 2 +- locales/ja/q.json | 14 +- locales/ja/scrims.json | 1 + locales/ja/team.json | 1 + locales/ja/tournament.json | 10 +- locales/ja/user.json | 3 +- locales/ko/calendar.json | 10 +- locales/ko/common.json | 53 +- locales/ko/forms.json | 12 +- locales/ko/friends.json | 19 + locales/ko/front.json | 34 +- locales/ko/q.json | 14 +- locales/ko/scrims.json | 1 + locales/ko/team.json | 1 + locales/ko/tournament.json | 8 +- locales/ko/user.json | 3 +- locales/nl/calendar.json | 10 +- locales/nl/common.json | 53 +- locales/nl/forms.json | 12 +- locales/nl/friends.json | 19 + locales/nl/front.json | 34 +- locales/nl/q.json | 14 +- locales/nl/scrims.json | 1 + locales/nl/team.json | 1 + locales/nl/tournament.json | 8 +- locales/nl/user.json | 5 +- locales/pl/calendar.json | 10 +- locales/pl/common.json | 53 +- locales/pl/forms.json | 12 +- locales/pl/friends.json | 19 + locales/pl/front.json | 34 +- locales/pl/q.json | 14 +- locales/pl/scrims.json | 1 + locales/pl/team.json | 1 + locales/pl/tournament.json | 8 +- locales/pl/user.json | 7 +- locales/pt-BR/calendar.json | 10 +- locales/pt-BR/common.json | 53 +- locales/pt-BR/forms.json | 12 +- locales/pt-BR/friends.json | 19 + locales/pt-BR/front.json | 34 +- locales/pt-BR/lfg.json | 2 +- locales/pt-BR/q.json | 14 +- locales/pt-BR/scrims.json | 1 + locales/pt-BR/team.json | 1 + locales/pt-BR/tournament.json | 10 +- locales/pt-BR/user.json | 6 +- locales/ru/calendar.json | 10 +- locales/ru/common.json | 53 +- locales/ru/forms.json | 12 +- locales/ru/friends.json | 19 + locales/ru/front.json | 37 +- locales/ru/lfg.json | 2 +- locales/ru/q.json | 14 +- locales/ru/scrims.json | 1 + locales/ru/team.json | 1 + locales/ru/tournament.json | 10 +- locales/ru/user.json | 7 +- locales/zh/calendar.json | 10 +- locales/zh/common.json | 53 +- locales/zh/forms.json | 12 +- locales/zh/friends.json | 19 + locales/zh/front.json | 34 +- locales/zh/lfg.json | 2 +- locales/zh/q.json | 14 +- locales/zh/scrims.json | 1 + locales/zh/team.json | 1 + locales/zh/tournament.json | 10 +- locales/zh/user.json | 3 +- migrations/120-custom-theme.js | 13 + migrations/121-friends-system.js | 56 + migrations/122-saved-calendar-event.js | 23 + migrations/123-splatoon-rotation.js | 16 + migrations/124-tournament-lfg.js | 77 + package-lock.json | 53 + package.json | 5 +- scripts/sync-splatoon-rotations.ts | 19 + scripts/sync-weapon-params.ts | 286 ++ types/intl-duration-format.d.ts | 23 + types/vite.d.ts | 3 +- vite.config.ts | 19 + 830 files changed, 35854 insertions(+), 16836 deletions(-) delete mode 100644 .beans.yml create mode 100644 TOURNAMENT_LFG_PLAN.md create mode 100644 TOURNAMENT_LFG_SPEC.md delete mode 100644 app/components/AddNewButton.module.css delete mode 100644 app/components/AddNewButton.tsx create mode 100644 app/components/Alert.module.css create mode 100644 app/components/Avatar.module.css create mode 100644 app/components/Chart.module.css create mode 100644 app/components/CustomThemeSelector.module.css create mode 100644 app/components/CustomThemeSelector.tsx delete mode 100644 app/components/CustomizedColorsInput.tsx create mode 100644 app/components/Divider.module.css create mode 100644 app/components/EventsList.module.css create mode 100644 app/components/EventsList.tsx create mode 100644 app/components/FormErrors.module.css create mode 100644 app/components/FormMessage.module.css create mode 100644 app/components/FriendCodePopover.tsx create mode 100644 app/components/Image.module.css create mode 100644 app/components/InfoPopover.module.css create mode 100644 app/components/Input.module.css create mode 100644 app/components/Label.module.css create mode 100644 app/components/Main.module.css create mode 100644 app/components/MobileNav.module.css create mode 100644 app/components/MobileNav.tsx create mode 100644 app/components/NotificationDot.module.css create mode 100644 app/components/NotificationDot.tsx create mode 100644 app/components/Pagination.module.css create mode 100644 app/components/RequiredHiddenInput.module.css create mode 100644 app/components/Section.module.css create mode 100644 app/components/SideNav.module.css create mode 100644 app/components/SideNav.tsx create mode 100644 app/components/StreamListItems.module.css create mode 100644 app/components/StreamListItems.tsx create mode 100644 app/components/SubNav.module.css create mode 100644 app/components/Table.module.css create mode 100644 app/components/TimePopover.module.css create mode 100644 app/components/YouTubeEmbed.module.css create mode 100644 app/components/elements/Calendar.module.css create mode 100644 app/components/elements/ChipRadio.module.css create mode 100644 app/components/elements/ChipRadio.tsx create mode 100644 app/components/elements/DatePicker.module.css create mode 100644 app/components/elements/Label.module.css create mode 100644 app/components/elements/Popover.module.css create mode 100644 app/components/elements/Switch.module.css create mode 100644 app/components/elements/Toast.browser.test.tsx create mode 100644 app/components/fuse/Fuse.tsx delete mode 100644 app/components/icons/Alert.tsx delete mode 100644 app/components/icons/ArchiveBox.tsx delete mode 100644 app/components/icons/ArrowDownOnSquare.tsx delete mode 100644 app/components/icons/ArrowLeft.tsx delete mode 100644 app/components/icons/ArrowLongLeft.tsx delete mode 100644 app/components/icons/ArrowUpOnSquare.tsx delete mode 100644 app/components/icons/Beaker.tsx delete mode 100644 app/components/icons/BeakerFilled.tsx delete mode 100644 app/components/icons/Bell.tsx delete mode 100644 app/components/icons/Calendar.tsx delete mode 100644 app/components/icons/ChartBar.tsx delete mode 100644 app/components/icons/Checkmark.tsx delete mode 100644 app/components/icons/ChevronDown.tsx delete mode 100644 app/components/icons/ChevronUp.tsx delete mode 100644 app/components/icons/ChevronUpDown.tsx delete mode 100644 app/components/icons/Clipboard.tsx delete mode 100644 app/components/icons/Clock.tsx delete mode 100644 app/components/icons/Cross.tsx delete mode 100644 app/components/icons/Download.tsx delete mode 100644 app/components/icons/Edit.tsx delete mode 100644 app/components/icons/Error.tsx delete mode 100644 app/components/icons/Eye.tsx delete mode 100644 app/components/icons/EyeSlash.tsx delete mode 100644 app/components/icons/Filter.tsx delete mode 100644 app/components/icons/FilterFilled.tsx delete mode 100644 app/components/icons/Fire.tsx delete mode 100644 app/components/icons/Hamburger.tsx delete mode 100644 app/components/icons/Heart.tsx delete mode 100644 app/components/icons/Key.tsx delete mode 100644 app/components/icons/Link.tsx delete mode 100644 app/components/icons/Lock.tsx delete mode 100644 app/components/icons/LogIn.tsx delete mode 100644 app/components/icons/LogOut.tsx delete mode 100644 app/components/icons/Map.tsx delete mode 100644 app/components/icons/MegaphoneIcon.tsx delete mode 100644 app/components/icons/Microphone.tsx delete mode 100644 app/components/icons/MicrophoneFilled.tsx delete mode 100644 app/components/icons/Minus.tsx delete mode 100644 app/components/icons/Pick.tsx delete mode 100644 app/components/icons/Plus.tsx delete mode 100644 app/components/icons/Puzzle.tsx delete mode 100644 app/components/icons/Refresh.tsx delete mode 100644 app/components/icons/RefreshArrows.tsx delete mode 100644 app/components/icons/Scale.tsx delete mode 100644 app/components/icons/Search.tsx delete mode 100644 app/components/icons/Sort.tsx delete mode 100644 app/components/icons/Speaker.tsx delete mode 100644 app/components/icons/SpeakerFilled.tsx delete mode 100644 app/components/icons/SpeakerX.tsx delete mode 100644 app/components/icons/SpeechBubble.tsx delete mode 100644 app/components/icons/SpeechBubbleFilled.tsx delete mode 100644 app/components/icons/Star.tsx delete mode 100644 app/components/icons/StarFilled.tsx delete mode 100644 app/components/icons/Trash.tsx delete mode 100644 app/components/icons/Trophy.tsx delete mode 100644 app/components/icons/Unlink.tsx delete mode 100644 app/components/icons/Unlock.tsx delete mode 100644 app/components/icons/User.tsx delete mode 100644 app/components/icons/Users.tsx create mode 100644 app/components/layout/ChatSidebar.module.css create mode 100644 app/components/layout/ChatSidebar.tsx create mode 100644 app/components/layout/Footer.module.css create mode 100644 app/components/layout/GlobalSearch.module.css create mode 100644 app/components/layout/GlobalSearch.tsx delete mode 100644 app/components/layout/NavDialog.tsx create mode 100644 app/components/layout/TopNavMenus.module.css create mode 100644 app/components/layout/TopNavMenus.tsx create mode 100644 app/components/layout/TopRightButtons.module.css create mode 100644 app/components/layout/UserItem.module.css delete mode 100644 app/components/layout/UserItem.tsx create mode 100644 app/components/layout/WeaponSearch.tsx delete mode 100644 app/components/ramp/Ramp.tsx create mode 100644 app/features/art/components/ArtGrid.module.css create mode 100644 app/features/articles/routes/a.module.css create mode 100644 app/features/badges/badges.module.css delete mode 100644 app/features/build-analyzer/analyzer.css create mode 100644 app/features/build-analyzer/routes/analyzer.module.css rename app/features/build-stats/{build-stats.css => routes/builds.$slug.stats.module.css} (50%) rename app/{styles/calendar-event.css => features/calendar/calendar-event.module.css} (54%) create mode 100644 app/features/calendar/calendar-new.module.css create mode 100644 app/features/calendar/components/BracketProgressionSelector.module.css create mode 100644 app/features/calendar/components/Tags.module.css delete mode 100644 app/features/calendar/components/TagsFormField.module.css create mode 100644 app/features/calendar/loaders/events.server.ts create mode 100644 app/features/calendar/routes/events.module.css create mode 100644 app/features/calendar/routes/events.tsx create mode 100644 app/features/chat/ChatProvider.tsx create mode 100644 app/features/chat/chat-provider-types.ts create mode 100644 app/features/chat/chat-utils.test.ts create mode 100644 app/features/chat/components/Chat.module.css create mode 100644 app/features/chat/routes/api.chat-users.ts create mode 100644 app/features/chat/useChatContext.ts create mode 100644 app/features/components-showcase/components-showcase.module.css create mode 100644 app/features/components-showcase/form-examples-schema.ts create mode 100644 app/features/components-showcase/routes/components.tsx create mode 100644 app/features/core/streams/streams.server.ts create mode 100644 app/features/friends/FriendRepository.server.test.ts create mode 100644 app/features/friends/FriendRepository.server.ts create mode 100644 app/features/friends/actions/friends.server.ts create mode 100644 app/features/friends/components/FriendMenu.tsx create mode 100644 app/features/friends/friends-constants.ts create mode 100644 app/features/friends/friends-schemas.server.ts create mode 100644 app/features/friends/friends-schemas.ts create mode 100644 app/features/friends/friends-utils.server.ts create mode 100644 app/features/friends/loaders/friends.server.ts create mode 100644 app/features/friends/routes/friends.module.css create mode 100644 app/features/friends/routes/friends.tsx create mode 100644 app/features/front-page/components/SplatoonRotations.module.css create mode 100644 app/features/front-page/components/SplatoonRotations.tsx rename app/features/info/{support.css => routes/support.module.css} (53%) create mode 100644 app/features/layout/core/sidenav-session.server.ts create mode 100644 app/features/layout/routes/sidenav.ts create mode 100644 app/features/leaderboards/components/TopTenPlayer.module.css create mode 100644 app/features/lfg/lfg-schemas.ts create mode 100644 app/features/links/routes/links.module.css create mode 100644 app/features/map-planner/components/Planner.module.css create mode 100644 app/features/map-planner/plans-global.css delete mode 100644 app/features/map-planner/plans.css delete mode 100644 app/features/object-damage-calculator/calculator.css create mode 100644 app/features/object-damage-calculator/routes/object-damage-calculator.module.css create mode 100644 app/features/plus-suggestions/plus.module.css create mode 100644 app/features/plus-voting/plus-voting-results.module.css create mode 100644 app/features/search/routes/search.ts create mode 100644 app/features/search/search-schemas.ts create mode 100644 app/features/search/search-types.ts create mode 100644 app/features/sendouq-settings/components/ModeMapPoolPicker.module.css create mode 100644 app/features/sendouq-settings/components/PreferenceRadioGroup.module.css delete mode 100644 app/features/sendouq-settings/q-settings.css create mode 100644 app/features/sendouq-settings/routes/q.settings.module.css delete mode 100644 app/features/sendouq/components/MemberAdder.module.css rename app/features/sendouq/routes/{trusters.ts => friends-for-adding.ts} (67%) create mode 100644 app/features/settings/routes/settings.global.css create mode 100644 app/features/settings/routes/settings.module.css create mode 100644 app/features/settings/routes/wave.svg create mode 100644 app/features/sidebar/core/StreamRanking.test.ts create mode 100644 app/features/sidebar/core/StreamRanking.ts create mode 100644 app/features/sidebar/core/sidebar.server.ts create mode 100644 app/features/splatoon-rotations/SplatoonRotationRepository.server.ts create mode 100644 app/features/splatoon-rotations/splatoon-rotations.server.ts rename app/features/team/actions/{t.server.test.ts => t.new.server.test.ts} (92%) rename app/features/team/actions/{t.server.ts => t.new.server.ts} (100%) create mode 100644 app/features/team/loaders/t.new.server.ts delete mode 100644 app/features/team/loaders/t.server.ts create mode 100644 app/features/team/routes/t.new.tsx delete mode 100644 app/features/team/team.css create mode 100644 app/features/team/team.module.css rename app/features/theme/core/{session.server.ts => theme-session.server.ts} (100%) delete mode 100644 app/features/top-search/top-search.css create mode 100644 app/features/top-search/top-search.module.css delete mode 100644 app/features/tournament-bracket/components/Bracket/bracket.css create mode 100644 app/features/tournament-bracket/components/Bracket/bracket.module.css create mode 100644 app/features/tournament-bracket/core/RunningTournaments.server.ts create mode 100644 app/features/tournament-bracket/core/RunningTournaments.test.ts delete mode 100644 app/features/tournament-bracket/tournament-bracket.css create mode 100644 app/features/tournament-bracket/tournament-bracket.module.css create mode 100644 app/features/tournament-lfg/TournamentLFGRepository.server.test.ts create mode 100644 app/features/tournament-lfg/TournamentLFGRepository.server.ts create mode 100644 app/features/tournament-lfg/actions/to.$id.looking.server.ts create mode 100644 app/features/tournament-lfg/components/LFGGroupCard.module.css create mode 100644 app/features/tournament-lfg/components/LFGGroupCard.tsx create mode 100644 app/features/tournament-lfg/loaders/to.$id.looking.server.ts create mode 100644 app/features/tournament-lfg/routes/to.$id.looking.module.css create mode 100644 app/features/tournament-lfg/routes/to.$id.looking.tsx create mode 100644 app/features/tournament-lfg/tournament-lfg-schemas.ts create mode 100644 app/features/tournament-lfg/tournament-lfg-utils.ts delete mode 100644 app/features/tournament-organization/tournament-organization.css create mode 100644 app/features/tournament-organization/tournament-organization.module.css delete mode 100644 app/features/tournament-subs/TournamentSubRepository.server.ts delete mode 100644 app/features/tournament-subs/actions/to.$id.subs.new.server.ts delete mode 100644 app/features/tournament-subs/actions/to.$id.subs.server.ts delete mode 100644 app/features/tournament-subs/loaders/to.$id.subs.new.server.ts delete mode 100644 app/features/tournament-subs/queries/deleteSub.server.ts delete mode 100644 app/features/tournament-subs/routes/to.$id.subs.module.css delete mode 100644 app/features/tournament-subs/routes/to.$id.subs.new.module.css delete mode 100644 app/features/tournament-subs/routes/to.$id.subs.new.tsx delete mode 100644 app/features/tournament-subs/tournament-subs-constants.ts delete mode 100644 app/features/tournament-subs/tournament-subs-schemas.server.ts create mode 100644 app/features/tournament/SavedCalendarEventRepository.server.ts delete mode 100644 app/features/tournament/queries/giveTrust.server.ts delete mode 100644 app/features/tournament/tournament.css create mode 100644 app/features/tournament/tournament.module.css create mode 100644 app/features/user-page/components/MutualFriends.module.css create mode 100644 app/features/user-page/components/MutualFriends.tsx delete mode 100644 app/features/user-page/routes/u.$identifier.admin.module.css create mode 100644 app/features/user-page/user-page.module.css delete mode 100644 app/features/user-search/loaders/u.server.ts create mode 100644 app/hooks/useMainContentWidth.ts delete mode 100644 app/hooks/useWindowSize.ts delete mode 100644 app/routines/deleteOldTrusts.ts create mode 100644 app/routines/syncSplatoonRotations.ts delete mode 100644 app/styles/badges.css delete mode 100644 app/styles/calendar-new.css delete mode 100644 app/styles/elements.css delete mode 100644 app/styles/layout.css create mode 100644 app/styles/normalize.css delete mode 100644 app/styles/plus-history.css delete mode 100644 app/styles/plus.css delete mode 100644 app/styles/reset.css delete mode 100644 app/styles/u.$identifier.module.css delete mode 100644 app/styles/u.css create mode 100644 app/utils/oklch-gamut.ts create mode 100644 app/utils/team-name-data.ts create mode 100644 app/utils/team-name.ts create mode 100644 docs/styles.md create mode 100644 e2e/events.spec.ts create mode 100644 e2e/friends.spec.ts create mode 100644 e2e/global-search.spec.ts create mode 100644 e2e/navigation.spec.ts delete mode 100644 knip.json create mode 100644 knip.ts create mode 100644 locales/da/friends.json create mode 100644 locales/de/friends.json create mode 100644 locales/en/friends.json create mode 100644 locales/es-ES/friends.json create mode 100644 locales/es-US/friends.json create mode 100644 locales/fr-CA/friends.json create mode 100644 locales/fr-EU/friends.json create mode 100644 locales/he/friends.json create mode 100644 locales/it/friends.json create mode 100644 locales/ja/friends.json create mode 100644 locales/ko/friends.json create mode 100644 locales/nl/friends.json create mode 100644 locales/pl/friends.json create mode 100644 locales/pt-BR/friends.json create mode 100644 locales/ru/friends.json create mode 100644 locales/zh/friends.json create mode 100644 migrations/120-custom-theme.js create mode 100644 migrations/121-friends-system.js create mode 100644 migrations/122-saved-calendar-event.js create mode 100644 migrations/123-splatoon-rotation.js create mode 100644 migrations/124-tournament-lfg.js create mode 100644 scripts/sync-splatoon-rotations.ts create mode 100644 scripts/sync-weapon-params.ts create mode 100644 types/intl-duration-format.d.ts diff --git a/.beans.yml b/.beans.yml deleted file mode 100644 index 3c66f5549..000000000 --- a/.beans.yml +++ /dev/null @@ -1,6 +0,0 @@ -beans: - path: .beans - prefix: sendou.ink-2- - id_length: 4 - default_status: todo - default_type: task diff --git a/.gitignore b/.gitignore index 68f1da06c..82b6dad19 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ node_modules .react-router/ .env translation-progress.md -**/*/__screenshots__ notes.md @@ -21,11 +20,17 @@ dump /scripts/output/* !/scripts/output/.gitkeep -/scripts/dicts/**/*.json +/scripts/dicts/**/* /scripts/badge /test-results/ /playwright-report/ /playwright/.cache/ +/.vitest-attachments +.vitest-attachments/ + +# Vitest auto-captured failure screenshots (numbered, without browser info) +# Real baselines have pattern: *-chromium-darwin.png +**/__screenshots__/**/*-[0-9].png .e2e-minio-started notepad.txt diff --git a/AGENTS.md b/AGENTS.md index dc43316b0..c6fee7e11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,8 @@ - use CSS modules - one file containing React code should have a matching CSS module file e.g. `Component.tsx` should have a file with the same root name i.e. `Component.module.css` - clsx library is used for conditional class names -- prefer using [CSS variables](../app/styles/vars.css) for theming +- prefer using [CSS variables](./app/styles/vars.css) for theming +- for simple styling, prefer [utility classes](./app/styles/utils.css) over creating a new class ## SQL diff --git a/TOURNAMENT_LFG_PLAN.md b/TOURNAMENT_LFG_PLAN.md new file mode 100644 index 000000000..f1dce2d37 --- /dev/null +++ b/TOURNAMENT_LFG_PLAN.md @@ -0,0 +1,329 @@ +# Tournament LFG Implementation Plan + +## Overview + +Add a new `/to/:id/looking` route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before the tournament starts. + +## Phase 1: Database Migration + +**File**: `migrations/118-tournament-lfg.js` + +Create three new tables: + +### TournamentLFGGroup +| Column | Type | Notes | +|--------|------|-------| +| id | integer primary key | Auto-increment | +| tournamentId | integer not null | FK to Tournament | +| tournamentTeamId | integer | FK to TournamentTeam (null for unregistered groups) | +| visibility | text | JSON (AssociationVisibility) | +| chatCode | text not null | Unique room code for group chat | +| createdAt | integer | Default now | + +### TournamentLFGGroupMember +| Column | Type | Notes | +|--------|------|-------| +| groupId | integer not null | FK to TournamentLFGGroup | +| userId | integer not null | FK to User | +| role | text not null | OWNER / MANAGER / REGULAR | +| note | text | Public note | +| isStayAsSub | integer | Boolean (0/1), default 0 | +| createdAt | integer | Default now | + +### TournamentLFGLike +| Column | Type | Notes | +|--------|------|-------| +| likerGroupId | integer not null | FK to TournamentLFGGroup | +| targetGroupId | integer not null | FK to TournamentLFGGroup | +| createdAt | integer | Default now | + +All tables use `STRICT` mode, `ON DELETE CASCADE` for FKs, and have indexes on FK columns. + +--- + +## Phase 2: TypeScript Types + +**File**: `app/db/tables.ts` + +Add interfaces for the three new tables following existing patterns (`GeneratedAlways`, `Generated`, `JSONColumnTypeNullable`). + +Add to the `DB` interface: +- `TournamentLFGGroup` +- `TournamentLFGGroupMember` +- `TournamentLFGLike` + +--- + +## Phase 3: Feature File Structure + +``` +app/features/tournament-lfg/ +├── TournamentLFGRepository.server.ts # Database operations +├── tournament-lfg-types.ts # TypeScript types (LFGGroup, LFGGroupMember, etc.) +├── tournament-lfg-schemas.server.ts # Zod validation schemas +├── tournament-lfg-constants.ts # Constants (note max length, etc.) +├── tournament-lfg-utils.ts # Utility functions +├── routes/ +│ └── to.$id.looking.tsx # Main route (loader + action + component) +├── loaders/ +│ └── to.$id.looking.server.ts # Data loader +├── actions/ +│ └── to.$id.looking.server.ts # Action handler +└── components/ + └── LFGGroupCard.tsx # Group card (mirrors SendouQ GroupCard pattern) +``` + +--- + +## Phase 4: Repository Implementation + +**File**: `app/features/tournament-lfg/TournamentLFGRepository.server.ts` + +Mirror `SQGroupRepository.server.ts` pattern. Key functions: + +**Group Management:** +- `findGroupsByTournamentId(tournamentId)` - Get all active groups +- `addMember(groupId, { userId, role, stayAsSub? })` - Add member to group +- `morphGroups({ survivingGroupId, otherGroupId })` - Merge two groups + +**Likes:** +- `addLike({ likerGroupId, targetGroupId })` - Add like +- `deleteLike({ likerGroupId, targetGroupId })` - Remove like +- `allLikesByGroupId(groupId)` - Get { given: [], received: [] } + +**Member Management:** +- `updateMemberNote({ groupId, userId, value })` - Update public note +- `updateMemberRole({ userId, groupId, role })` - Change role +- `updateStayAsSub({ groupId, userId, value })` - Toggle sub preference +- `kickMember({ groupId, userId })` - Owner kicks member + +**Tournament Integration:** +- `cleanupForTournamentStart(tournamentId)` - Delete groups, preserve stayAsSub members +- `getSubsForTournament(tournamentId)` - Get users who opted to stay as sub + +--- + +## Phase 5: Route Implementation + +### 5.1 Route Registration + +**File**: `app/routes.ts` + +Add inside `/to/:id` children: +```typescript +route("looking", "features/tournament-lfg/routes/to.$id.looking.tsx"), +``` + +### 5.2 Loader + +**File**: `app/features/tournament-lfg/loaders/to.$id.looking.server.ts` + +Returns: +- `groups` - All visible groups (filtered by visibility) +- `ownGroup` - User's current group (if any) +- `likes` - { given: [], received: [] } for own group +- `privateNotes` - User's private notes on other players (reuse from SendouQ) +- `lastUpdated` - Timestamp for auto-refresh + +### 5.3 Action + +**File**: `app/features/tournament-lfg/actions/to.$id.looking.server.ts` + +Actions: +- `JOIN_QUEUE` - Create new group +- `LIKE` / `UNLIKE` - Like/unlike another group +- `ACCEPT` - Accept mutual like (triggers team creation/merge) +- `LEAVE_GROUP` - Leave current group +- `KICK_FROM_GROUP` - Owner kicks member +- `GIVE_MANAGER` / `REMOVE_MANAGER` - Role management +- `UPDATE_NOTE` - Update public note +- `UPDATE_STAY_AS_SUB` - Toggle sub preference +- `REFRESH_GROUP` - Refresh activity timestamp + +**ACCEPT Action Flow:** +1. Verify mutual like exists +2. Check if either group has `tournamentTeamId` +3. If neither: Create new `TournamentTeam` (use auto-generated name) +4. Merge groups (use `morphGroups`) +5. Add all members to `TournamentTeamMember` +6. Send `TO_LFG_TEAM_FORMED` notification +7. If team reaches `maxMembersPerTeam`: delete the LFG group + +### 5.4 Component + +**File**: `app/features/tournament-lfg/routes/to.$id.looking.tsx` + +Structure (mirror `/q/looking`): +- Three-column desktop layout: My Group | Groups | Invitations +- Tab structure on mobile +- Reuse `GroupCard` display pattern (weapons, VC, tier) +- Reuse `MemberAdder` for quick-add trusted players +- Reuse `GroupLeaver` component +- Chat integration for groups with 2+ members +- "Stay as sub" checkbox in join form + +--- + +## Phase 6: Tournament Integration + +### 6.1 Add "Looking" Tab + +**File**: `app/features/tournament/routes/to.$id.tsx` + +Add new `SubNavLink` (show only before tournament starts, not for invitationals): +```tsx +{!tournament.hasStarted && !tournament.isInvitational && ( + {t("tournament:tabs.looking")} +)} +``` + +### 6.2 Tournament.ts Getter + +**File**: `app/features/tournament-bracket/core/Tournament.ts` + +Add: +```typescript +get lfgEnabled() { + return !this.isInvitational && !this.hasStarted && this.subsFeatureEnabled; +} +``` + +### 6.3 Auto-cleanup on Tournament Start + +When tournament bracket starts, call `TournamentLFGRepository.cleanupForTournamentStart(tournamentId)`: +- Delete all `TournamentLFGGroup` records +- Preserve `TournamentLFGGroupMember` records where `stayAsSub = 1` for subs list + +--- + +## Phase 7: Notifications + +**File**: `app/features/notifications/notifications-types.ts` + +Add three new notification types: + +```typescript +| NotificationItem< + "TO_LFG_LIKED", + { + tournamentId: number; + tournamentName: string; + likerUsername: string; + } + > +| NotificationItem< + "TO_LFG_TEAM_FORMED", + { + tournamentId: number; + tournamentName: string; + teamName: string; + tournamentTeamId: number; + } + > +| NotificationItem< + "TO_LFG_CHAT_MESSAGE", + { + tournamentId: number; + tournamentName: string; + teamName: string; + tournamentTeamId: number; + } + > +``` + +**File**: `app/features/notifications/notifications-utils.ts` + +Add notification link handlers and icon mappings. + +--- + +## Phase 8: Translations + +**File**: `public/locales/en/tournament.json` + +Add keys: +- `tabs.looking` +- `lfg.join.header`, `lfg.join.stayAsSub`, `lfg.join.visibility` +- `lfg.myGroup.header`, `lfg.myGroup.empty` +- `lfg.groups.header`, `lfg.groups.empty` +- `lfg.invitations.header`, `lfg.invitations.empty`, `lfg.invitations.accept` +- `lfg.actions.like`, `lfg.actions.unlike`, `lfg.actions.leave` + +Run `npm run i18n:sync` after adding. + +--- + +## Phase 9: Group Merging Logic + +When two groups merge via ACCEPT: + +| Group A has team | Group B has team | Result | +|------------------|------------------|--------| +| No | No | Create new TournamentTeam, both join it | +| Yes | No | B's members join A's team | +| No | Yes | A's members join B's team | +| Yes | Yes | Accepting team absorbs liker team (accepting team name persists) | + +Auto-generated team name format: `"'s Team"` (e.g., "Sendou's Team" - can be changed later on registration page) + +After merge: +- Combined group stays in LFG queue +- When `maxMembersPerTeam` reached, group is auto-removed from queue + +--- + +## Key Files to Reference + +| Purpose | File | +|---------|------| +| Repository pattern | `app/features/sendouq/SQGroupRepository.server.ts` | +| Route pattern | `app/features/sendouq/routes/q.looking.tsx` | +| Action pattern | `app/features/sendouq/actions/q.looking.server.ts` | +| GroupCard UI | `app/features/sendouq/components/GroupCard.tsx` | +| Tournament tabs | `app/features/tournament/routes/to.$id.tsx` | +| Team creation | `app/features/tournament/TournamentTeamRepository.server.ts` | +| Notification types | `app/features/notifications/notifications-types.ts` | +| Visibility type | `app/features/associations/associations-types.ts` | + +--- + +## Implementation Order + +1. Migration (100-tournament-lfg.js) +2. Types (tables.ts + tournament-lfg-types.ts) +3. Repository (TournamentLFGRepository.server.ts) +4. Schemas (tournament-lfg-schemas.server.ts) +5. Loader (to.$id.looking.server.ts) +6. Action (to.$id.looking.server.ts) +7. Route component (to.$id.looking.tsx) +8. Tournament integration (tab, cleanup hook) +9. Notifications (types + utils) +10. Translations +11. Testing + +--- + +## Verification + +1. **Manual Testing:** + - Join LFG as solo player + - Create group with 2 players + - Like another group, verify notification sent + - Accept mutual like, verify team created + - Verify team appears on registration page + - Test "stay as sub" checkbox + - Test visibility filtering + +2. **Unit Tests:** + - Repository functions (create, merge, delete, likes) + - Visibility filtering logic + +3. **E2E Tests:** + - Full flow: join -> like -> accept -> team formed + - Leave group + - Kick from group + +4. **Run checks:** + ```bash + npm run checks + ``` diff --git a/TOURNAMENT_LFG_SPEC.md b/TOURNAMENT_LFG_SPEC.md new file mode 100644 index 000000000..102431ccd --- /dev/null +++ b/TOURNAMENT_LFG_SPEC.md @@ -0,0 +1,166 @@ +# Tournament LFG Feature Spec + +## Overview + +New `/to/:id/looking` route that provides SendouQ-style matchmaking for tournament team formation. Players and teams can find each other before tournament starts. + +## Route + +`/to/:id/looking` (new route, mirrors `/q/looking`) + +## Data Model + +Separate tables from SendouQ (cleaner separation): + +### TournamentLFGGroup + +| Column | Type | Description | +|--------|------|-------------| +| id | number | Primary key | +| tournamentTeamId | number | null | FK to TournamentTeam | +| visibility | string | JSON visibility info (/scrims style) | +| chatCode | string | Unique room code for group chat | +| createdAt | number | Timestamp | + +### TournamentLFGGroupMember + +| Column | Type | Description | +|--------|------|-------------| +| groupId | number | FK to TournamentLFGGroup | +| userId | number | FK to User | +| role | string | OWNER / MANAGER / REGULAR | +| note | string | Public note visible to group members | +| stayAsSub | boolean | Convert to sub if team not formed by start | +| createdAt | number | Timestamp | + +### TournamentLFGLike + +| Column | Type | Description | +|--------|------|-------------| +| likerGroupId | number | FK to TournamentLFGGroup | +| targetGroupId | number | FK to TournamentLFGGroup | +| createdAt | number | Timestamp | + +### TournamentSub + +Redundant, removed. + +## Who Can Join + +- **Solo players** - Looking for a team +- **Partial groups (2-3 players)** - Looking for more members +- **Already-registered tournament teams** - Recruiting up to `maxMembersPerTeam` + +## Features + +### Joining the Queue + +- Players reuse weapon/VC data from `/q/settings` (`User.qWeaponPool`, `User.vc`, `User.languages`) +- Checkbox on join: "Add me as sub if I don't find a team" +- Support for notes (like SendouQ public member notes) +- Uses schema based SendouForm (forms.md for details) + +### Visibility System (Scrims-style) + +- **Base visibility**: team/org/public +- **Not-found visibility**: Time-delayed expansion if no match found +- Uses existing `AssociationVisibility` system from scrims + +### Likes & Matching + +1. Players/groups can like each other +2. Target group receives `TO_LFG_LIKED` notification +3. On mutual like, accepting party sees accept button +4. Accept click triggers: + - If neither party is registered team → create a team (some default name is used) + - If one party is registered team → Other party joins that team + - If both parties are teams, the accepting team absorbs the liker team (accepting team's name is used for the new merged team) +5. After merge, combined group stays in queue to recruit more members + +### Team Formation + +- **Immediate registration**: When first two players merge, default name is used +- Newly formed team stays in LFG queue +- Teams can grow up to `maxMembersPerTeam` (typically 6 for 4v4) +- When `maxMembersPerTeam` is reached, team is automatically removed from the queue +- Solo players liking registered teams get absorbed as new members + +### Tournament Start Auto-Cleanup + +When tournament starts: +1. All unregistered LFG groups are deleted +2. Players who checked "stay as sub" are shown in a simple list "`TournamentLFGGroupMember` reused here even if they technically are no longer members of anything) +3. Their sub data uses existing `/q/settings` weapon/VC preferences + +## Notifications + +| Type | When | Meta | +|------|------|------| +| `TO_LFG_LIKED` | Someone likes your group | `{ tournamentId, tournamentName, likerUsername }` | +| `TO_LFG_TEAM_FORMED` | You join/form a team via LFG | `{ tournamentId, tournamentName, teamName, tournamentTeamId }` | +| `TO_LFG_CHAT_MESSAGE` | Chat message sent | `{ tournamentId, tournamentName, teamName, tournamentTeamId }` | + +## UI + +### Reuse from `/q/looking` + +- `GroupCard` component (weapons, VC, tier display) +- Tab structure (My Group, Groups, Invitations) +- `MemberAdder` component (invite link, quick add), note for these same invite link and quick add endpoint is used as on /to/:id/register page +- `GroupLeaver` component +- Private user notes system + +### Tab Structure + +1. **My Group** - Current group members, invite link, leave button, chat (if 2+ members) +2. **Groups** - Other groups looking, with like/unlike buttons +3. **Invitations** - Groups that have liked your group, with accept/decline + +### Accept Flow + +Simple button click (SendouQ-style) + +## Files to Create/Modify + +### New Files + +``` +app/features/tournament-lfg/ +├── core/ +│ └── TournamentLFG.server.ts # Main class (like SendouQ.server.ts) +├── routes/ +│ ├── to.$id.looking.tsx # Main LFG page +│ └── to.$id.looking.new.tsx # Join LFG form (if needed) +├── loaders/ +│ └── to.$id.looking.server.ts # Data loader +├── actions/ +│ └── to.$id.looking.server.ts # Action handler +├── components/ +│ └── (reuse from sendouq where possible) +├── TournamentLFGRepository.server.ts # Database queries +├── tournament-lfg-types.ts # TypeScript types +├── tournament-lfg-schemas.server.ts # Zod validation +└── tournament-lfg-constants.ts # Constants +``` + +### Migrations + +``` +migrations/XXX-tournament-lfg.js # Create new tables +``` + +### Modified Files + +- `app/routes.ts` - Add new route +- `app/features/notifications/notifications-types.ts` - Add new notification types +- `app/features/tournament-bracket/core/Tournament.ts` - Add LFG-related getters +- `app/features/tournament/components/TournamentTabs.tsx` - Add "Looking" tab + +## Differences to SendouQ + +- no invite code (tournament teams have their own invite code by default) +- no expiredAt + +## Open Questions + +- How we should define the autogenerated tournament team name? diff --git a/app/browser-test-setup.ts b/app/browser-test-setup.ts index d377d5e60..ad31b411c 100644 --- a/app/browser-test-setup.ts +++ b/app/browser-test-setup.ts @@ -3,13 +3,13 @@ import { initReactI18next } from "react-i18next"; import { config } from "~/modules/i18n/config"; import { resources } from "~/modules/i18n/resources.browser"; -import "~/styles/common.css"; -import "~/styles/elements.css"; -import "~/styles/flags.css"; -import "~/styles/layout.css"; -import "~/styles/reset.css"; -import "~/styles/utils.css"; import "~/styles/vars.css"; +import "~/styles/normalize.css"; +import "~/styles/common.css"; +import "~/styles/utils.css"; +import "~/styles/flags.css"; + +document.documentElement.classList.add("dark"); i18next.use(initReactI18next).init({ ...config, diff --git a/app/components/AbilitiesSelector.module.css b/app/components/AbilitiesSelector.module.css index 2520ab20c..c0150472c 100644 --- a/app/components/AbilitiesSelector.module.css +++ b/app/components/AbilitiesSelector.module.css @@ -24,9 +24,9 @@ .abilityButton { padding: var(--s-0-5); - border-color: var(--abilities-button-bg); + border: var(--border-style); border-radius: 50%; - background-color: var(--abilities-button-bg); + background-color: var(--color-bg-ability); } .abilityButton.isDragging { diff --git a/app/components/Ability.module.css b/app/components/Ability.module.css index 60c93da4a..db787ca9e 100644 --- a/app/components/Ability.module.css +++ b/app/components/Ability.module.css @@ -2,20 +2,19 @@ width: var(--ability-size); height: var(--ability-size); padding: 0; - border: 2px solid var(--theme-transparent); + border: var(--border-style-high); border-radius: 50%; border-right: 0; border-bottom: 0; - background: var(--bg-ability); + background: var(--color-bg-ability); background-size: 100%; - box-shadow: 0 0 0 1px var(--bg-ability); transform: scale(1); transition: all 0.1s ease; user-select: none; } .isDragTarget { - background: var(--abilities-button-bg); + background: var(--color-bg-ability); transform: scale(1.15); } diff --git a/app/components/AddNewButton.module.css b/app/components/AddNewButton.module.css deleted file mode 100644 index e1eb80251..000000000 --- a/app/components/AddNewButton.module.css +++ /dev/null @@ -1,48 +0,0 @@ -.addNewButton { - --picture-size: 18px; - --icon-size: 14px; - --button-height: 28px; - --border-width: 2px; - --inner-border-radius: 6px; - - padding-block: 0 !important; - padding-inline: 0 !important; - background-color: transparent !important; - height: var(--button-height); -} - -.iconsContainer { - display: flex; - gap: var(--s-0-5); - align-items: center; - justify-content: center; - background-color: var(--theme-very-transparent); - height: calc(var(--button-height) - var(--border-width) * 2); - border-radius: var(--inner-border-radius) 0 0 var(--inner-border-radius); - padding-inline: var(--s-1); -} - -.iconsContainer > svg { - max-width: var(--icon-size); - max-height: var(--icon-size); - min-width: var(--icon-size); - min-height: var(--icon-size); - color: var(--theme); - stroke-width: 4px; -} - -.iconsContainer > picture { - max-width: var(--picture-size); - max-height: var(--picture-size); - min-width: var(--picture-size); - min-height: var(--picture-size); -} - -.textContainer { - padding-inline: var(--s-1-5); - background-color: var(--theme) !important; - height: calc(var(--button-height) - var(--border-width) * 2); - display: flex; - align-items: center; - border-radius: 0 var(--inner-border-radius) var(--inner-border-radius) 0; -} diff --git a/app/components/AddNewButton.tsx b/app/components/AddNewButton.tsx deleted file mode 100644 index 67dac9e5a..000000000 --- a/app/components/AddNewButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { LinkButton } from "~/components/elements/Button"; -import { Image } from "~/components/Image"; -import { PlusIcon } from "~/components/icons/Plus"; -import { navIconUrl } from "~/utils/urls"; - -import styles from "./AddNewButton.module.css"; - -interface AddNewButtonProps { - to: string; - navIcon: string; -} - -export function AddNewButton({ to, navIcon }: AddNewButtonProps) { - return ( - - - - - - New - - ); -} diff --git a/app/components/Alert.module.css b/app/components/Alert.module.css new file mode 100644 index 000000000..6077d339b --- /dev/null +++ b/app/components/Alert.module.css @@ -0,0 +1,43 @@ +.alert { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + border-radius: var(--radius-box); + background-color: var(--color-info-low); + color: var(--color-info-high); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + gap: var(--s-2); + margin-inline: auto; + padding-block: var(--s-1-5); + padding-inline: var(--s-3) var(--s-4); + text-align: center; + + & > svg { + height: 1.75rem; + } +} + +.tiny { + font-size: var(--font-xs); + + & > svg { + height: 1.25rem; + } +} + +.warning { + background-color: var(--color-warning-low); + color: var(--color-warning-high); +} + +.error { + background-color: var(--color-error-low); + color: var(--color-error-high); +} + +.success { + background-color: var(--color-success-low); + color: var(--color-success-high); +} diff --git a/app/components/Alert.tsx b/app/components/Alert.tsx index d5860711b..8d302435f 100644 --- a/app/components/Alert.tsx +++ b/app/components/Alert.tsx @@ -1,9 +1,8 @@ import clsx from "clsx"; +import { Check, CircleAlert, OctagonAlert, TriangleAlert } from "lucide-react"; import type * as React from "react"; import { assertUnreachable } from "~/utils/types"; -import { AlertIcon } from "./icons/Alert"; -import { CheckmarkIcon } from "./icons/Checkmark"; -import { ErrorIcon } from "./icons/Error"; +import styles from "./Alert.module.css"; export type AlertVariation = "INFO" | "WARNING" | "ERROR" | "SUCCESS"; @@ -22,11 +21,11 @@ export function Alert({ }) { return (
{" "} @@ -38,13 +37,13 @@ export function Alert({ function Icon({ variation }: { variation: AlertVariation }) { switch (variation) { case "INFO": - return ; + return ; case "WARNING": - return ; + return ; case "ERROR": - return ; + return ; case "SUCCESS": - return ; + return ; default: assertUnreachable(variation); } diff --git a/app/components/Avatar.module.css b/app/components/Avatar.module.css new file mode 100644 index 000000000..ab67a14eb --- /dev/null +++ b/app/components/Avatar.module.css @@ -0,0 +1,21 @@ +.identicon { + filter: blur(99px); + transition: filter 0.3s; +} + +.loaded { + filter: blur(0); +} + +.avatarWrapper { + flex-shrink: 0; + width: fit-content; + height: fit-content; + background-color: var(--color-bg-higher); + border-radius: var(--radius-avatar); + overflow: hidden; +} + +.avatarWrapper img { + display: block; +} diff --git a/app/components/Avatar.tsx b/app/components/Avatar.tsx index 13e0e28b1..5eef624b9 100644 --- a/app/components/Avatar.tsx +++ b/app/components/Avatar.tsx @@ -1,12 +1,15 @@ import clsx from "clsx"; import * as React from "react"; import type { Tables } from "~/db/tables"; +import { useIsMounted } from "~/hooks/useIsMounted"; import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls"; +import styles from "./Avatar.module.css"; const dimensions = { xxxs: 16, xxxsm: 20, xxs: 24, + xxsm: 32, xs: 36, sm: 44, xsm: 62, @@ -15,49 +18,141 @@ const dimensions = { lg: 125, } as const; +const identiconCache = new Map(); + +function hashString(str: string) { + let hash = 5381; + + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0; + } + + return hash; +} + +function generateColors(hash: number) { + const hue = hash % 360; + const saturation = 65 + ((hash >>> 8) % 20); + const lightness = 50 + ((hash >>> 16) % 20); + + return { + background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`, + foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`, + }; +} + +function generateIdenticon(input: string, size = 128, gridSize = 5) { + const cacheKey = `${input}-${size}-${gridSize}`; + const cached = identiconCache.get(cacheKey); + if (cached) return cached; + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + + const dpr = window.devicePixelRatio || 1; + canvas.width = size * dpr; + canvas.height = size * dpr; + canvas.style.width = `${size}px`; + canvas.style.height = `${size}px`; + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + + const insetRatio = 1 / Math.sqrt(2); + const cellSize = Math.floor((size * insetRatio) / gridSize); + const actualSize = cellSize * gridSize; + const offset = Math.floor((size - actualSize) / 2); + const halfGrid = Math.ceil(gridSize / 2); + + const patternHash = hashString(input); + const colorHash = hashString(input.split("").reverse().join("")); + + const colors = generateColors(colorHash); + ctx.fillStyle = colors.background; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = colors.foreground; + + const path = new Path2D(); + + for (let row = 0; row < gridSize; row++) { + for (let col = 0; col < halfGrid; col++) { + const bitIndex = row * halfGrid + col; + const shouldFill = (patternHash >>> bitIndex) & 1; + + if (shouldFill) { + const x = offset + col * cellSize; + const y = offset + row * cellSize; + + path.rect(x, y, cellSize, cellSize); + + const mirrorCol = gridSize - 1 - col; + if (col !== mirrorCol) { + const mirrorX = offset + mirrorCol * cellSize; + path.rect(mirrorX, y, cellSize, cellSize); + } + } + } + } + + ctx.fill(path); + + const dataUrl = canvas.toDataURL(); + identiconCache.set(cacheKey, dataUrl); + return dataUrl; +} + export function Avatar({ user, url, + identiconInput, size = "sm", className, alt = "", ...rest }: { user?: Pick; - url?: string; + url?: string | null; + identiconInput?: string; className?: string; alt?: string; size: keyof typeof dimensions; } & React.ButtonHTMLAttributes) { const [isErrored, setIsErrored] = React.useState(false); - // TODO: just show text... my profile? - // TODO: also show this if discordAvatar is stale and 404's + const [loaded, setLoaded] = React.useState(false); + const isClient = useIsMounted(); - // biome-ignore lint/correctness/useExhaustiveDependencies: every avatar error state is unique and we want to avoid using key on every avatar - React.useEffect(() => { - setIsErrored(false); - }, [user?.discordAvatar]); + const isIdenticon = + !url && (!user?.discordAvatar || isErrored || identiconInput); - const src = - url ?? - (user?.discordAvatar && !isErrored + const identiconSource = identiconInput ?? user?.discordId ?? "unknown"; + + const src = url + ? url + : user?.discordAvatar && !isErrored ? discordAvatarUrl({ discordAvatar: user.discordAvatar, discordId: user.discordId, size: size === "lg" || size === "xmd" ? "lg" : "sm", }) - : BLANK_IMAGE_URL); // avoid broken image placeholder + : isClient + ? generateIdenticon(identiconSource, dimensions[size], 7) + : BLANK_IMAGE_URL; return ( - {alt} setIsErrored(true)} - {...rest} - /> +
+ {alt} setIsErrored(true)} + onLoad={() => setLoaded(true)} + {...rest} + /> +
); } diff --git a/app/components/BuildCard.module.css b/app/components/BuildCard.module.css index 69fa92ceb..0db3d1ac4 100644 --- a/app/components/BuildCard.module.css +++ b/app/components/BuildCard.module.css @@ -3,34 +3,28 @@ display: flex; flex-direction: column; padding: var(--s-3); - border-radius: var(--rounded); - background-color: var(--bg-lighter); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); gap: var(--s-3); } .private { - background-color: var(--bg-lighter-transparent); + background-color: var(--color-bg-higher); } .privateText { display: flex; justify-content: center; - font-weight: var(--semi-bold); + font-weight: var(--weight-semi); gap: var(--s-1); } -.privateIcon { - width: 16px; - margin-block-end: var(--s-1); - stroke-width: 2px; -} - .title { overflow: hidden; height: 2.5rem; - font-size: var(--fonts-sm); + font-size: var(--font-sm); line-height: 1.25; - word-wrap: break-all; + word-wrap: break-word; } .topRow { @@ -40,7 +34,7 @@ .dateAuthorRow { display: flex; - font-size: var(--fonts-xxs); + font-size: var(--font-2xs); gap: var(--s-1); } @@ -63,7 +57,12 @@ position: relative; padding: var(--s-0-5); border-radius: 50%; - background-color: var(--bg-darker-very-transparent); + background-color: var(--color-bg); + + &:focus-within { + outline: var(--focus-ring); + outline-offset: 1px; + } } .top500 { @@ -74,9 +73,9 @@ .weaponText { padding-left: var(--s-1); - color: var(--text-lighter); - font-size: var(--fonts-xxs); - font-weight: var(--semi-bold); + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); } .weapons { @@ -104,7 +103,7 @@ .gear { border-radius: 50%; - background-color: var(--bg-darker-very-transparent); + background-color: var(--color-bg); overflow: visible; } @@ -113,14 +112,9 @@ height: 100%; align-items: flex-end; justify-content: center; - gap: var(--s-4); -} - -.icon { - width: 1.2rem; - height: 1.2rem; + gap: var(--s-2); } .smallText { - font-size: var(--fonts-xxs) !important; + font-size: var(--font-2xs) !important; } diff --git a/app/components/BuildCard.tsx b/app/components/BuildCard.tsx index aeea864f0..519703fd5 100644 --- a/app/components/BuildCard.tsx +++ b/app/components/BuildCard.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import { Lock, MessageCircleMore, SquarePen, Trash } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import type { GearType, Tables, UserWithPlusTier } from "~/db/tables"; @@ -30,10 +31,6 @@ import { LinkButton, SendouButton } from "./elements/Button"; import { SendouPopover } from "./elements/Popover"; import { FormWithConfirm } from "./FormWithConfirm"; import { Image } from "./Image"; -import { EditIcon } from "./icons/Edit"; -import { LockIcon } from "./icons/Lock"; -import { SpeechBubbleIcon } from "./icons/SpeechBubble"; -import { TrashIcon } from "./icons/Trash"; interface BuildProps { build: Pick< @@ -119,11 +116,10 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
) : null} -
+
{build.private ? (
- {" "} - {t("common:build.private")} + {t("common:build.private")}
) : null}
- {t("common:pages.analyzer")} - + {description ? ( } + icon={} className={styles.smallText} /> } @@ -200,14 +202,14 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) { {canEdit && ( <> - - + icon={} + /> } + shape="circle" + size="small" + icon={} className={styles.smallText} variant="minimal-destructive" type="submit" diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index 635f9a014..3e0a946be 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -1,3 +1,4 @@ +import { RefreshCcw } from "lucide-react"; import * as React from "react"; import { isRouteErrorResponse, @@ -14,7 +15,6 @@ import { } from "~/utils/urls"; import { SendouButton } from "./elements/Button"; import { Image } from "./Image"; -import { RefreshArrowsIcon } from "./icons/RefreshArrows"; import { Main } from "./Main"; export function Catcher() { @@ -165,7 +165,7 @@ function RefreshPageButton() { return ( window.location.reload()} - icon={} + icon={} > Refresh page diff --git a/app/components/Chart.module.css b/app/components/Chart.module.css new file mode 100644 index 000000000..3b31452e0 --- /dev/null +++ b/app/components/Chart.module.css @@ -0,0 +1,34 @@ +.container { + height: var(--chart-height, 175px); + width: var(--chart-width); + background-color: var(--chart-bg, var(--color-bg-high)); + border-radius: var(--radius-box); +} + +.tooltip { + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg); + padding: var(--s-1) var(--s-2); + font-weight: var(--weight-semi); + font-size: var(--font-sm); + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.tooltipValue { + margin-inline-start: auto; + min-width: 40px; +} + +.dot { + background-color: var(--dot-color); + border-radius: 100%; + width: 12px; + height: 12px; +} + +.dotFocused { + outline: 3px solid var(--dot-color-outline); +} diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index c791ba953..940723ad3 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { Theme, useTheme } from "~/features/theme/core/provider"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useTimeFormat } from "~/hooks/useTimeFormat"; +import styles from "./Chart.module.css"; export default function Chart({ options, @@ -62,11 +63,11 @@ export default function Chart({ ); if (!isMounted) { - return
; + return
; } return ( -
+
@@ -124,19 +125,19 @@ function ChartTooltip({ }; return ( -
+

{header()} {headerSuffix}

{dataPoints.map((dataPoint, index) => { - const color = dataPoint.style?.fill ?? "var(--theme)"; + const color = dataPoint.style?.fill ?? "var(--color-accent)"; return (
-
- {dataPoint.originalSeries.label} -
-
+
{dataPoint.originalSeries.label}
+
{dataPoint.secondaryValue} {valueSuffix}
diff --git a/app/components/CopyToClipboardPopover.tsx b/app/components/CopyToClipboardPopover.tsx index 599fa36e9..d7d143f12 100644 --- a/app/components/CopyToClipboardPopover.tsx +++ b/app/components/CopyToClipboardPopover.tsx @@ -1,10 +1,9 @@ +import { Check, Clipboard } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useCopyToClipboard } from "react-use"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { CheckmarkIcon } from "~/components/icons/Checkmark"; -import { ClipboardIcon } from "~/components/icons/Clipboard"; interface CopyToClipboardPopoverProps { url: string; @@ -36,7 +35,7 @@ export function CopyToClipboardPopover({ size="miniscule" variant="minimal" onPress={() => copyToClipboard(url)} - icon={copySuccess ? : } + icon={copySuccess ? : } > {t("common:actions.copyToClipboard")} diff --git a/app/components/CustomThemeSelector.module.css b/app/components/CustomThemeSelector.module.css new file mode 100644 index 000000000..a27d9e4d6 --- /dev/null +++ b/app/components/CustomThemeSelector.module.css @@ -0,0 +1,103 @@ +.customThemeSelector { + display: flex; + flex-direction: column; + gap: var(--s-4); + padding: var(--s-4); + border: var(--border-style); + border-radius: var(--radius-field); + background-color: var(--color-bg-medium); + position: relative; + overflow: hidden; +} + +.customThemeSelectorSupporter { + display: none; +} + +.customThemeSelectorNoSupporter { + position: absolute; + inset: 0; + z-index: 10; + backdrop-filter: blur(3px); + display: flex; + flex-direction: column; + justify-content: center; +} + +.customThemeSelectorInfo { + display: flex; + flex-direction: column; + gap: var(--s-2); + margin-bottom: var(--s-4); + text-align: center; + background-color: var(--color-bg); + padding: var(--s-2) var(--s-4); + border-block: var(--border-style); +} + +.customThemeSelectorActions { + display: grid; + gap: var(--s-2); + grid-template-columns: 1fr auto; +} + +.themeSliders { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.themeSliderRow { + display: grid; + grid-template-columns: 40% auto; + grid-template-rows: auto auto; + gap: var(--s-1) var(--s-2); + align-items: center; +} + +.hueSlider::-webkit-slider-runnable-track { + background: linear-gradient( + to right, + oklch(65% 0.15 0), + oklch(65% 0.15 60), + oklch(65% 0.15 120), + oklch(65% 0.15 180), + oklch(65% 0.15 240), + oklch(65% 0.15 300), + oklch(65% 0.15 360) + ) !important; +} + +.hueSlider::-moz-range-track { + background: linear-gradient( + to right, + oklch(65% 0.15 0), + oklch(65% 0.15 60), + oklch(65% 0.15 120), + oklch(65% 0.15 180), + oklch(65% 0.15 240), + oklch(65% 0.15 300), + oklch(65% 0.15 360) + ) !important; +} + +.chatColorPreview { + color: oklch(from var(--color-text-accent) l c var(--_chat-h)); +} + +.themeShare { + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.themeShareActions { + display: flex; + gap: var(--s-2); + align-items: center; +} + +.themeShareInput input { + flex: 1; + min-width: 0; +} diff --git a/app/components/CustomThemeSelector.tsx b/app/components/CustomThemeSelector.tsx new file mode 100644 index 000000000..3dd4c324f --- /dev/null +++ b/app/components/CustomThemeSelector.tsx @@ -0,0 +1,506 @@ +import { Check, Clipboard, PencilLine } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useCopyToClipboard } from "react-use"; +import { + CUSTOM_THEME_VARS, + type CustomTheme, + type CustomThemeVar, +} from "~/db/tables"; +import { + ACCENT_CHROMA_MULTIPLIERS, + BASE_CHROMA_MULTIPLIERS, + clampThemeToGamut, + type ThemeInput, +} from "~/utils/oklch-gamut"; +import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/zod"; +import styles from "./CustomThemeSelector.module.css"; +import { Divider } from "./Divider"; +import { LinkButton, SendouButton } from "./elements/Button"; +import { SendouSwitch } from "./elements/Switch"; +import { Label } from "./Label"; + +const COLOR_SLIDERS = [ + { + id: "base-hue", + inputKey: "baseHue", + min: THEME_INPUT_LIMITS.BASE_HUE_MIN, + max: THEME_INPUT_LIMITS.BASE_HUE_MAX, + step: 1, + labelKey: "baseHue", + isHue: true, + }, + { + id: "base-chroma", + inputKey: "baseChroma", + min: THEME_INPUT_LIMITS.BASE_CHROMA_MIN, + max: THEME_INPUT_LIMITS.BASE_CHROMA_MAX, + step: 0.001, + labelKey: "baseChroma", + isHue: false, + }, + { + id: "accent-hue", + inputKey: "accentHue", + min: THEME_INPUT_LIMITS.ACCENT_HUE_MIN, + max: THEME_INPUT_LIMITS.ACCENT_HUE_MAX, + step: 1, + labelKey: "accentHue", + isHue: true, + }, + { + id: "accent-chroma", + inputKey: "accentChroma", + min: THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN, + max: THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX, + step: 0.01, + labelKey: "accentChroma", + isHue: false, + }, +] as const; + +const RADIUS_SLIDERS = [ + { + id: "radius-box", + inputKey: "radiusBox", + min: THEME_INPUT_LIMITS.RADIUS_MIN, + max: THEME_INPUT_LIMITS.RADIUS_MAX, + step: THEME_INPUT_LIMITS.RADIUS_STEP, + labelKey: "boxes", + }, + { + id: "radius-field", + inputKey: "radiusField", + min: THEME_INPUT_LIMITS.RADIUS_MIN, + max: THEME_INPUT_LIMITS.RADIUS_MAX, + step: THEME_INPUT_LIMITS.RADIUS_STEP, + labelKey: "fields", + }, + { + id: "radius-selector", + inputKey: "radiusSelector", + min: THEME_INPUT_LIMITS.RADIUS_MIN, + max: THEME_INPUT_LIMITS.RADIUS_MAX, + step: THEME_INPUT_LIMITS.RADIUS_STEP, + labelKey: "selectors", + }, +] as const; + +const BORDER_SLIDERS = [ + { + id: "border-width", + inputKey: "borderWidth", + min: THEME_INPUT_LIMITS.BORDER_WIDTH_MIN, + max: THEME_INPUT_LIMITS.BORDER_WIDTH_MAX, + step: THEME_INPUT_LIMITS.BORDER_WIDTH_STEP, + labelKey: "borderWidth", + }, +] as const; + +const SIZE_SLIDERS = [ + { + id: "size-field", + inputKey: "sizeField", + min: THEME_INPUT_LIMITS.SIZE_MIN, + max: THEME_INPUT_LIMITS.SIZE_MAX, + step: THEME_INPUT_LIMITS.SIZE_STEP, + labelKey: "fields", + }, + { + id: "size-selector", + inputKey: "sizeSelector", + min: THEME_INPUT_LIMITS.SIZE_MIN, + max: THEME_INPUT_LIMITS.SIZE_MAX, + step: THEME_INPUT_LIMITS.SIZE_STEP, + labelKey: "selectors", + }, + { + id: "size-spacing", + inputKey: "sizeSpacing", + min: THEME_INPUT_LIMITS.SIZE_MIN, + max: THEME_INPUT_LIMITS.SIZE_MAX, + step: THEME_INPUT_LIMITS.SIZE_STEP, + labelKey: "spacings", + }, +] as const; + +type ThemeInputKey = + | (typeof COLOR_SLIDERS)[number]["inputKey"] + | (typeof RADIUS_SLIDERS)[number]["inputKey"] + | (typeof BORDER_SLIDERS)[number]["inputKey"] + | (typeof SIZE_SLIDERS)[number]["inputKey"] + | "chatHue"; + +const THEME_STRING_KEYS: readonly ThemeInputKey[] = [ + ...COLOR_SLIDERS.map((s) => s.inputKey), + ...RADIUS_SLIDERS.map((s) => s.inputKey), + ...BORDER_SLIDERS.map((s) => s.inputKey), + ...SIZE_SLIDERS.map((s) => s.inputKey), + "chatHue", +]; + +function themeInputToString(input: ThemeInput): string { + return THEME_STRING_KEYS.map((key) => { + const value = input[key]; + return value === null ? "_" : String(value); + }).join(";"); +} + +function themeInputFromString(str: string): ThemeInput | null { + const parts = str.split(";"); + if (parts.length !== THEME_STRING_KEYS.length) return null; + + const raw: Record = {}; + for (let i = 0; i < THEME_STRING_KEYS.length; i++) { + const key = THEME_STRING_KEYS[i]; + const part = parts[i].trim(); + + if (key === "chatHue" && part === "_") { + raw[key] = null; + continue; + } + + const num = Number(part); + if (Number.isNaN(num)) return null; + raw[key] = num; + } + + const parsed = themeInputSchema.safeParse(raw); + return parsed.success ? parsed.data : null; +} + +const DEFAULT_THEME_INPUT: ThemeInput = { + baseHue: 268, + baseChroma: 0.05, + accentHue: 253, + accentChroma: 0.24, + chatHue: null, + radiusBox: 3, + radiusField: 2, + radiusSelector: 2, + borderWidth: 2, + sizeField: 1, + sizeSelector: 1, + sizeSpacing: 1, +}; + +function themeInputFromCustomTheme(customTheme: CustomTheme): ThemeInput { + return { + baseHue: customTheme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue, + baseChroma: + (customTheme["--_base-c-2"] ?? 0) / BASE_CHROMA_MULTIPLIERS[2] || + DEFAULT_THEME_INPUT.baseChroma, + accentHue: customTheme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue, + accentChroma: + (customTheme["--_acc-c-2"] ?? 0) / ACCENT_CHROMA_MULTIPLIERS[2] || + DEFAULT_THEME_INPUT.accentChroma, + chatHue: customTheme["--_chat-h"], + radiusBox: customTheme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox, + radiusField: + customTheme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField, + radiusSelector: + customTheme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector, + borderWidth: + customTheme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth, + sizeField: customTheme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField, + sizeSelector: + customTheme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector, + sizeSpacing: + customTheme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing, + }; +} + +function applyThemeInput(input: ThemeInput) { + const clampedTheme = clampThemeToGamut(input); + + for (const [key, value] of Object.entries(clampedTheme)) { + document.documentElement.style.setProperty(key, String(value)); + } +} + +function ThemeSlider({ + id, + inputKey, + min, + max, + step, + label, + isHue, + value, + onChange, +}: { + id: string; + inputKey: ThemeInputKey; + min: number; + max: number; + step: number; + label: string; + isHue?: boolean; + value: number; + onChange: (inputKey: ThemeInputKey, value: number) => void; +}) { + return ( +
+ + onChange(inputKey, Number(e.target.value))} + className={isHue ? styles.hueSlider : undefined} + /> +
+ ); +} + +export function CustomThemeSelector({ + initialTheme, + isSupporter, + isPersonalTheme, + onSave, + onReset, + hidePatreonInfo, +}: { + initialTheme: CustomTheme | null | undefined; + isSupporter: boolean; + isPersonalTheme: boolean; + onSave: (themeInput: ThemeInput) => void; + onReset: () => void; + hidePatreonInfo?: boolean; +}) { + const { t } = useTranslation(["common"]); + + const initialThemeInput = initialTheme + ? themeInputFromCustomTheme(initialTheme) + : DEFAULT_THEME_INPUT; + + const [themeInput, setThemeInput] = + React.useState(initialThemeInput); + + const handleSliderChange = (inputKey: ThemeInputKey, value: number) => { + const updatedInput = { ...themeInput, [inputKey]: value }; + setThemeInput(updatedInput); + applyThemeInput(updatedInput); + }; + + const chatHueEnabled = themeInput.chatHue !== null; + + const handleChatHueToggle = (isSelected: boolean) => { + const updatedInput = { + ...themeInput, + chatHue: isSelected ? 0 : null, + }; + setThemeInput(updatedInput); + applyThemeInput(updatedInput); + }; + + const handleSave = () => { + onSave(themeInput); + }; + + const handleReset = () => { + setThemeInput(DEFAULT_THEME_INPUT); + CUSTOM_THEME_VARS.forEach((varDef: CustomThemeVar) => { + document.documentElement.style.removeProperty(varDef); + }); + onReset(); + }; + + return ( +
+ {hidePatreonInfo ? null : ( +
+
+

{t("common:settings.customTheme.patreonText")}

+ + {t("common:settings.customTheme.joinPatreon")} + +
+
+ )} + {t("common:settings.customTheme.colors")} +
+ {COLOR_SLIDERS.map((slider) => ( + + ))} + {isPersonalTheme ? ( +
+ + {t("common:settings.customTheme.chatHueToggle")} + +
+ ) : null} + {chatHueEnabled && isPersonalTheme ? ( +
+ +
+ ) : null} +
+ {t("common:settings.customTheme.radius")} +
+ {RADIUS_SLIDERS.map((slider) => ( + + ))} +
+ {isPersonalTheme ? ( + <> + {t("common:settings.customTheme.sizes")} +
+ {SIZE_SLIDERS.map((slider) => ( + + ))} +
+ + {t("common:settings.customTheme.borders")} + +
+ {BORDER_SLIDERS.map((slider) => ( + + ))} +
+ + ) : null} + + { + setThemeInput(imported); + applyThemeInput(imported); + }} + /> +
+ + {t("common:actions.save")} + + + {t("common:actions.reset")} + +
+
+ ); +} + +function ThemeShareInput({ + themeInput, + onImport, +}: { + themeInput: ThemeInput; + onImport: (input: ThemeInput) => void; +}) { + const { t } = useTranslation(["common"]); + const [state, copyToClipboard] = useCopyToClipboard(); + const [copySuccess, setCopySuccess] = React.useState(false); + + const themeString = themeInputToString(themeInput); + + React.useEffect(() => { + if (!state.value) return; + + setCopySuccess(true); + const timeout = setTimeout(() => setCopySuccess(false), 2000); + + return () => clearTimeout(timeout); + }, [state]); + + const handlePaste = async () => { + const text = await navigator.clipboard.readText(); + const parsed = themeInputFromString(text); + if (parsed) onImport(parsed); + }; + + return ( +
+ +
+ + : } + onPress={() => copyToClipboard(themeString)} + aria-label={t("common:settings.customTheme.copy")} + /> + } + onPress={handlePaste} + aria-label={t("common:settings.customTheme.paste")} + /> +
+
+ ); +} diff --git a/app/components/CustomizedColorsInput.tsx b/app/components/CustomizedColorsInput.tsx deleted file mode 100644 index db6279b52..000000000 --- a/app/components/CustomizedColorsInput.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import clsx from "clsx"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useDebounce } from "react-use"; -import { CUSTOM_CSS_VAR_COLORS } from "~/features/user-page/user-page-constants"; -import { SendouButton } from "./elements/Button"; -import { InfoPopover } from "./InfoPopover"; -import { AlertIcon } from "./icons/Alert"; -import { CheckmarkIcon } from "./icons/Checkmark"; -import { Label } from "./Label"; - -type CustomColorsRecord = Partial< - Record<(typeof CUSTOM_CSS_VAR_COLORS)[number], string> ->; - -type ContrastCombination = [ - Exclude<(typeof CUSTOM_CSS_VAR_COLORS)[number], "bg-lightest">, - Exclude<(typeof CUSTOM_CSS_VAR_COLORS)[number], "bg-lightest">, -]; - -type ContrastArray = { - colors: ContrastCombination; - contrast: { - AA: { failed: boolean; ratio: string }; - AAA: { failed: boolean; ratio: string }; - }; -}[]; - -export function CustomizedColorsInput({ - initialColors, - value: controlledValue, - onChange, -}: { - initialColors?: Record | null; - value?: Record | null; - onChange?: (value: Record | null) => void; -}) { - const { t } = useTranslation(); - const [colors, setColors] = React.useState( - controlledValue ?? initialColors ?? {}, - ); - - const [defaultColors, setDefaultColors] = React.useState< - Record[] - >([]); - const [contrasts, setContrast] = React.useState([]); - - const updateColors = (newColors: CustomColorsRecord) => { - setColors(newColors); - if (onChange) { - const filtered = colorsWithDefaultsFilteredOut(newColors, defaultColors); - const hasValues = Object.keys(filtered).length > 0; - onChange(hasValues ? (filtered as Record) : null); - } - }; - - useDebounce( - () => { - for (const color in colors) { - const value = - colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? ""; - document.documentElement.style.setProperty(`--preview-${color}`, value); - } - - setContrast(handleContrast(defaultColors, colors)); - }, - 100, - [colors], - ); - - React.useEffect(() => { - const colors = CUSTOM_CSS_VAR_COLORS.map((color) => { - return { - [color]: getComputedStyle(document.documentElement).getPropertyValue( - `--${color}`, - ), - }; - }); - setDefaultColors(colors); - - return () => { - document.documentElement.removeAttribute("style"); - }; - }, []); - - return ( -
- -
- {t("custom.colors.title")} -
-
-
- - {!onChange ? ( - - ) : null} -
- {CUSTOM_CSS_VAR_COLORS.filter( - (cssVar) => cssVar !== "bg-lightest", - ).map((cssVar) => { - return ( - -
{t(`custom.colors.${cssVar}`)}
- { - const extras: Record = {}; - if (cssVar === "bg-lighter") { - extras["bg-lightest"] = `${e.target.value}80`; - } - updateColors({ - ...colors, - ...extras, - [cssVar]: e.target.value, - }); - }} - data-testid={`color-input-${cssVar}`} - /> - { - const newColors: Record = { - ...colors, - }; - if (cssVar === "bg-lighter") { - newColors["bg-lightest"] = defaultColors.find( - (color) => color["bg-lightest"], - )?.["bg-lightest"]; - } - updateColors({ - ...newColors, - [cssVar]: defaultColors.find((color) => color[cssVar])?.[ - cssVar - ], - }); - }} - > - {t("actions.reset")} - -
- ); - })} -
- - - - - - - - - - - - {contrasts.map((contrast) => { - return ( - - - - - - - ); - })} - -
{t("custom.colors.contrast.first-color")}{t("custom.colors.contrast.second-color")}AAAAA
{t(`custom.colors.${contrast.colors[0]}`)}{t(`custom.colors.${contrast.colors[1]}`)} - {contrast.contrast.AA.failed ? ( - - ) : ( - - )} - {contrast.contrast.AA.ratio} - - {contrast.contrast.AAA.failed ? ( - - ) : ( - - )} - {contrast.contrast.AAA.ratio} -
-
-
- ); -} - -function colorsWithDefaultsFilteredOut( - colors: CustomColorsRecord, - defaultColors: Record[], -): CustomColorsRecord { - const colorsWithoutDefaults: CustomColorsRecord = {}; - for (const color in colors) { - if ( - colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]] !== - defaultColors.find((c) => c[color])?.[color] - ) { - colorsWithoutDefaults[color as keyof CustomColorsRecord] = - colors[color as (typeof CUSTOM_CSS_VAR_COLORS)[number]]; - } - } - return colorsWithoutDefaults; -} - -function handleContrast( - defaultColors: Record[], - colors: CustomColorsRecord, -) { - /* - Excluded because bg-lightest is not visible to the user, - tho these should be checked as well: - ["bg-lightest", "text"], - ["bg-lightest", "theme-secondary"], - */ - const combinations: ContrastCombination[] = [ - ["bg", "text"], - ["bg", "text-lighter"], - ["bg-darker", "text"], - ["bg-darker", "theme"], - ["bg-lighter", "text-lighter"], - ["bg-lighter", "theme"], - ["bg-lighter", "theme-secondary"], - ]; - - const results: ContrastArray = []; - - for (const [A, B] of combinations) { - const valueA = - colors[A as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? undefined; - const valueB = - colors[B as (typeof CUSTOM_CSS_VAR_COLORS)[number]] ?? undefined; - - const colorA = valueA ?? defaultColors.find((color) => color[A])?.[A]; - const colorB = valueB ?? defaultColors.find((color) => color[B])?.[B]; - - if (!colorA || !colorB) continue; - - const parsedA = colorA.includes("rgb") ? parseCSSVar(colorA) : colorA; - const parsedB = colorB.includes("rgb") ? parseCSSVar(colorB) : colorB; - - results.push({ - colors: [A, B], - contrast: checkContrast(parsedA, parsedB), - }); - } - - return results; -} - -function parseCSSVar(cssVar: string) { - const regex = /rgb\((\d+)\s+(\d+)\s+(\d+)(?:\s+\/\s+(\d+%?))?\)/; - const match = cssVar.match(regex); - - if (!match) { - return "#000000"; - } - - const r = Number.parseInt(match[1], 10); - const g = Number.parseInt(match[2], 10); - const b = Number.parseInt(match[3], 10); - - let alpha = 255; - if (match[4]) { - const percentage = Number.parseInt(match[4], 10); - alpha = Math.round((percentage / 100) * 255); - } - - const toHex = (value: number) => { - const hex = value.toString(16); - return hex.length === 1 ? `0${hex}` : hex; - }; - - if (match[4]) { - return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alpha)}`; - } - - return `#${toHex(r)}${toHex(g)}${toHex(b)}`; -} - -function checkContrast(colorA: string, colorB: string) { - const rgb1 = hexToRgb(colorA); - const rgb2 = hexToRgb(colorB); - - const luminanceA = calculateLuminance(rgb1); - const luminanceB = calculateLuminance(rgb2); - - const light = Math.max(luminanceA, luminanceB); - const dark = Math.min(luminanceA, luminanceB); - const ratio = (light + 0.05) / (dark + 0.05); - - return { - AA: { - failed: ratio < 4.5, - ratio: ratio.toFixed(1), - }, - AAA: { - failed: ratio < 7, - ratio: ratio.toFixed(1), - }, - }; -} - -function hexToRgb(hex: string) { - const noHash = hex.replace(/^#/, ""); - - const r = Number.parseInt(noHash.substring(0, 2), 16); - const g = Number.parseInt(noHash.substring(2, 4), 16); - const b = Number.parseInt(noHash.substring(4, 6), 16); - - if (noHash.length === 8) { - const alpha = Number.parseInt(noHash.substring(6, 8), 16) / 255; - return [ - Math.round(r * alpha), - Math.round(g * alpha), - Math.round(b * alpha), - ]; - } - - return [r, g, b]; -} - -function calculateLuminance(rgb: number[]) { - const [r, g, b] = rgb.map((value) => { - const normalized = value / 255; - - return normalized <= 0.03928 - ? normalized / 12.92 - : ((normalized + 0.055) / 1.055) ** 2.4; - }); - - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -} diff --git a/app/components/Divider.module.css b/app/components/Divider.module.css new file mode 100644 index 000000000..8633ebfbb --- /dev/null +++ b/app/components/Divider.module.css @@ -0,0 +1,28 @@ +.divider { + display: flex; + width: 100%; + align-items: center; + color: var(--color-text-accent); + font-size: var(--font-lg); + text-align: center; + + &::before, + &::after { + flex: 1; + min-width: 1rem; + border-bottom: 2px solid var(--color-bg-high); + content: ""; + } + + &:not(:empty)::before { + margin-right: 0.25em; + } + + &:not(:empty)::after { + margin-left: 0.25em; + } +} + +.smallText { + font-size: var(--font-sm); +} diff --git a/app/components/Divider.tsx b/app/components/Divider.tsx index d4d49bbf2..6514a5b39 100644 --- a/app/components/Divider.tsx +++ b/app/components/Divider.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import styles from "./Divider.module.css"; export function Divider({ children, @@ -10,7 +11,11 @@ export function Divider({ smallText?: boolean; }) { return ( -
+
{children}
); diff --git a/app/components/EventsList.module.css b/app/components/EventsList.module.css new file mode 100644 index 000000000..237139854 --- /dev/null +++ b/app/components/EventsList.module.css @@ -0,0 +1,8 @@ +.dayHeader { + padding: var(--s-2) var(--s-2) var(--s-1); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + color: var(--color-text-high); + text-transform: uppercase; + letter-spacing: 0.05em; +} diff --git a/app/components/EventsList.tsx b/app/components/EventsList.tsx new file mode 100644 index 000000000..1e455ce22 --- /dev/null +++ b/app/components/EventsList.tsx @@ -0,0 +1,101 @@ +import { isToday, isTomorrow } from "date-fns"; +import { useTranslation } from "react-i18next"; +import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server"; +import styles from "./EventsList.module.css"; +import { ListLink } from "./SideNav"; + +export function EventsList({ + events, + onClick, +}: { + events: SidebarEvent[]; + onClick?: () => void; +}) { + const { t, i18n } = useTranslation(["front"]); + + if (events.length === 0) { + return ( +
+ {t("front:sideNav.noEvents")} +
+ ); + } + + const getDayKey = (timestamp: number) => { + const date = new Date(timestamp * 1000); + return date.toDateString(); + }; + + const formatDayHeader = (date: Date) => { + if (isToday(date)) { + const rtf = new Intl.RelativeTimeFormat(i18n.language, { + numeric: "auto", + }); + const str = rtf.format(0, "day"); + return str.charAt(0).toUpperCase() + str.slice(1); + } + if (isTomorrow(date)) { + const rtf = new Intl.RelativeTimeFormat(i18n.language, { + numeric: "auto", + }); + const str = rtf.format(1, "day"); + return str.charAt(0).toUpperCase() + str.slice(1); + } + return date.toLocaleDateString(i18n.language, { + weekday: "long", + month: "short", + day: "numeric", + }); + }; + + const formatTime = (date: Date) => { + return date.toLocaleTimeString(i18n.language, { + hour: "numeric", + minute: "2-digit", + }); + }; + + const groupedEvents = events.reduce>( + (acc, event) => { + const key = getDayKey(event.startTime); + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(event); + return acc; + }, + {}, + ); + + const dayKeys = Object.keys(groupedEvents); + + return ( + <> + {dayKeys.map((dayKey) => { + const dayEvents = groupedEvents[dayKey]; + const firstDate = new Date(dayEvents[0].startTime * 1000); + + return ( +
+
{formatDayHeader(firstDate)}
+ {dayEvents.map((event) => ( + + {event.scrimStatus === "booked" + ? t("front:sideNav.scrimVs", { opponent: event.name }) + : event.scrimStatus === "looking" + ? t("front:sideNav.lookingForScrim") + : event.name} + + ))} +
+ ); + })} + + ); +} diff --git a/app/components/FormErrors.module.css b/app/components/FormErrors.module.css new file mode 100644 index 000000000..f299076b0 --- /dev/null +++ b/app/components/FormErrors.module.css @@ -0,0 +1,7 @@ +.container { + font-size: var(--font-sm); + + & > h4 { + color: var(--color-error); + } +} diff --git a/app/components/FormErrors.tsx b/app/components/FormErrors.tsx index bbf3ded1a..0b2cc2dd3 100644 --- a/app/components/FormErrors.tsx +++ b/app/components/FormErrors.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import { useActionData } from "react-router"; import type { Namespace } from "~/modules/i18n/resources.server"; +import styles from "./FormErrors.module.css"; export function FormErrors({ namespace }: { namespace: Namespace }) { const { t } = useTranslation(["common", namespace]); @@ -11,7 +12,7 @@ export function FormErrors({ namespace }: { namespace: Namespace }) { } return ( -
+

{t("common:forms.errors.title")}:

    {actionData.errors.map((error) => ( diff --git a/app/components/FormMessage.module.css b/app/components/FormMessage.module.css new file mode 100644 index 000000000..94d4d872a --- /dev/null +++ b/app/components/FormMessage.module.css @@ -0,0 +1,17 @@ +.error { + display: block; + color: var(--color-error); + font-size: var(--font-xs); + margin-block-start: var(--label-margin); +} + +.info { + display: block; + color: var(--color-text-high); + font-size: var(--font-xs); + margin-block-start: var(--label-margin); +} + +.noMargin { + margin-block-start: 0; +} diff --git a/app/components/FormMessage.tsx b/app/components/FormMessage.tsx index f1d6397cc..54562586a 100644 --- a/app/components/FormMessage.tsx +++ b/app/components/FormMessage.tsx @@ -1,5 +1,6 @@ import clsx from "clsx"; import type * as React from "react"; +import styles from "./FormMessage.module.css"; export function FormMessage({ children, @@ -18,8 +19,8 @@ export function FormMessage({
    diff --git a/app/components/FriendCodeInput.tsx b/app/components/FriendCodeInput.tsx index c78a16fe3..667566b19 100644 --- a/app/components/FriendCodeInput.tsx +++ b/app/components/FriendCodeInput.tsx @@ -23,6 +23,7 @@ export function FriendCodeInput({ return ( +
    {t("common:fc.title")}
    -
    {t("common:fc.helpText")}
    -
    +
    {t("common:fc.whereToFind")}
    {!friendCode ? ( - Save + {t("common:actions.save")} ) : null}
    diff --git a/app/components/FriendCodePopover.tsx b/app/components/FriendCodePopover.tsx new file mode 100644 index 000000000..87911f1d6 --- /dev/null +++ b/app/components/FriendCodePopover.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { FormMessage } from "~/components/FormMessage"; +import { FriendCodeInput } from "~/components/FriendCodeInput"; +import { useUser } from "~/features/auth/core/user"; + +export function FriendCodePopover({ size }: { size?: "small" }) { + const { t } = useTranslation(["common"]); + const user = useUser(); + const friendCode = user?.friendCode; + + return ( + + {friendCode ? `SW-${friendCode}` : t("common:fc.set")} + + } + > +
    + + {t("common:fc.altingWarning")} + {friendCode ? ( + {t("common:fc.changeHelp")} + ) : null} +
    +
    + ); +} diff --git a/app/components/GearSelect.tsx b/app/components/GearSelect.tsx index 476e4219e..1b1ad6763 100644 --- a/app/components/GearSelect.tsx +++ b/app/components/GearSelect.tsx @@ -47,8 +47,6 @@ export function GearSelect({ search={{ placeholder: t("common:forms.gearSearch.search.placeholder"), }} - className={styles.selectWidthWider} - popoverClassName={styles.selectWidthWider} selectedKey={value} defaultSelectedKey={initialValue} onSelectionChange={(value) => onChange?.(value as any)} @@ -57,7 +55,7 @@ export function GearSelect({ > {({ key, items: gear, brandId, idx }) => ( +
    {title} {tier.isPlus ? ( {title} ) : null}
    diff --git a/app/components/InfoPopover.module.css b/app/components/InfoPopover.module.css new file mode 100644 index 000000000..d56d2ad05 --- /dev/null +++ b/app/components/InfoPopover.module.css @@ -0,0 +1,24 @@ +.trigger { + border: var(--border-style-high); + border-radius: 100%; + background-color: transparent; + color: var(--color-text); + font-size: var(--font-md); + padding: var(--s-0-5); + width: var(--selector-size); + height: var(--selector-size); + display: flex; + align-items: center; + justify-content: center; + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; + } +} + +.triggerTiny { + width: var(--selector-size-sm); + height: var(--selector-size-sm); + font-size: var(--font-xs); +} diff --git a/app/components/InfoPopover.tsx b/app/components/InfoPopover.tsx index 7c8e56e5d..c520ffa48 100644 --- a/app/components/InfoPopover.tsx +++ b/app/components/InfoPopover.tsx @@ -1,6 +1,7 @@ import clsx from "clsx"; import { Button } from "react-aria-components"; import { SendouPopover } from "./elements/Popover"; +import styles from "./InfoPopover.module.css"; export function InfoPopover({ children, @@ -15,14 +16,9 @@ export function InfoPopover({ ? diff --git a/app/components/Input.module.css b/app/components/Input.module.css new file mode 100644 index 000000000..4fc32217b --- /dev/null +++ b/app/components/Input.module.css @@ -0,0 +1,60 @@ +.container { + display: flex; + font-size: var(--font-sm); + outline: none; + border: var(--border-style); + border-radius: var(--radius-field); + background-color: var(--color-bg); + height: var(--field-size); + width: 100%; + + & svg { + color: var(--color-text-high); + height: calc(var(--field-size) / 2); + margin: auto; + margin-right: 15px; + } + + &:has(input:user-invalid) { + outline: var(--focus-ring-error); + outline-offset: 1px; + } + + &:focus-within { + outline: var(--focus-ring); + outline-offset: 1px; + } + + input { + border-radius: var(--radius-field); + padding: 0 var(--field-padding); + outline: none; + width: 100%; + background-color: inherit; + color: inherit; + border: none; + + &::placeholder { + color: var(--color-text-high); + } + } +} + +.readOnly { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + outline: none; +} + +.addon { + display: grid; + border-radius: var(--radius-field) 0 0 var(--radius-field); + background-color: var(--color-bg-high); + color: var(--color-text-high); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + padding-inline: var(--s-2); + place-items: center; + white-space: nowrap; +} diff --git a/app/components/Input.tsx b/app/components/Input.tsx index d0b219b2a..82468d9ab 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import styles from "./Input.module.css"; export function Input({ name, @@ -20,8 +21,10 @@ export function Input({ value, placeholder, onChange, + onKeyDown, disableAutoComplete = false, readOnly, + ref, }: { name?: string; id?: string; @@ -42,17 +45,21 @@ export function Input({ value?: string; placeholder?: string; onChange?: (e: React.ChangeEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; disableAutoComplete?: boolean; readOnly?: boolean; + ref?: React.Ref; }) { return (
    - {leftAddon ?
    {leftAddon}
    : null} + {leftAddon ?
    {leftAddon}
    : null} label { + margin: 0; + } +} + +.value { + color: var(--color-text-high); + font-size: var(--font-2xs); + margin-block-start: -5px; +} + +.valueWarning { + color: var(--color-warning); +} + +.valueError { + color: var(--color-error); +} diff --git a/app/components/Label.tsx b/app/components/Label.tsx index 89382874b..51db154d2 100644 --- a/app/components/Label.tsx +++ b/app/components/Label.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import styles from "./Label.module.css"; type LabelProps = Pick< React.DetailedHTMLProps< @@ -27,12 +28,12 @@ export function Label({ spaced = true, }: LabelProps) { return ( -
    +
    {valueLimits ? ( -
    +
    {valueLimits.current}/{valueLimits.max}
    ) : null} @@ -41,8 +42,8 @@ export function Label({ } function lengthWarning(valueLimits: NonNullable) { - if (valueLimits.current > valueLimits.max) return "error"; - if (valueLimits.current / valueLimits.max >= 0.9) return "warning"; + if (valueLimits.current > valueLimits.max) return styles.valueError; + if (valueLimits.current / valueLimits.max >= 0.9) return styles.valueWarning; return; } diff --git a/app/components/Main.module.css b/app/components/Main.module.css new file mode 100644 index 000000000..30c6f3782 --- /dev/null +++ b/app/components/Main.module.css @@ -0,0 +1,24 @@ +.main { + container-type: inline-size; + padding: var(--layout-main-padding); + margin-bottom: var(--s-32); + min-height: calc(100dvh - var(--layout-nav-height)); +} + +.normal { + width: 100%; + max-width: 48rem; + margin-inline: auto; +} + +.narrow { + width: 100%; + max-width: 24rem; + margin-inline: auto; +} + +.wide { + width: 100%; + max-width: 72rem; + margin-inline: auto; +} diff --git a/app/components/Main.tsx b/app/components/Main.tsx index 89e537f75..e90048964 100644 --- a/app/components/Main.tsx +++ b/app/components/Main.tsx @@ -1,7 +1,6 @@ import clsx from "clsx"; import type * as React from "react"; -import { isRouteErrorResponse, useRouteError } from "react-router"; -import { useHasRole } from "~/modules/permissions/hooks"; +import styles from "./Main.module.css"; export const Main = ({ children, @@ -18,49 +17,40 @@ export const Main = ({ bigger?: boolean; style?: React.CSSProperties; }) => { - const error = useRouteError(); - const isMinorSupporter = useHasRole("MINOR_SUPPORT"); - const showLeaderboard = - import.meta.env.VITE_PLAYWIRE_PUBLISHER_ID && - !isMinorSupporter && - !isRouteErrorResponse(error); - return ( -
    -
    - {children} -
    -
    +
    + {children} +
    ); }; +export { styles as mainStyles }; + export const containerClassName = (width: "narrow" | "normal" | "wide") => { if (width === "narrow") { - return "half-width"; + return styles.narrow; } if (width === "wide") { - return "bigger"; + return styles.wide; } - return "main"; + return styles.normal; }; diff --git a/app/components/MapPoolSelector.module.css b/app/components/MapPoolSelector.module.css index b7a123fc8..ee2eead91 100644 --- a/app/components/MapPoolSelector.module.css +++ b/app/components/MapPoolSelector.module.css @@ -1,15 +1,3 @@ -.templateSelection { - display: grid; - gap: var(--s-2); - grid-template-columns: 1fr; -} - -@media screen and (min-width: 640px) { - .templateSelection { - grid-template-columns: 1fr 1fr; - } -} - .stageRow { display: flex; width: 100%; @@ -23,23 +11,23 @@ flex-grow: 1; align-items: center; justify-content: space-between; - border-radius: var(--rounded); - background-color: var(--bg-lighter); - font-size: var(--fonts-xs); - font-weight: var(--semi-bold); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); + font-size: var(--font-xs); + font-weight: var(--weight-semi); gap: var(--s-2); padding-block: var(--s-1-5); padding-inline: var(--s-3); } -@media screen and (min-width: 640px) { +@container (width >= 560px) { .stageNameRow { flex-direction: row; } } .stageImage { - border-radius: var(--rounded); + border-radius: var(--radius-box); } .modeButtonsContainer { @@ -51,25 +39,29 @@ .modeButton { padding: var(--s-1); - border: 2px solid var(--bg-darker); - border-radius: var(--rounded-full); - background-color: transparent; - color: var(--theme); + border: var(--border-style); + border-radius: var(--radius-full); + background-color: var(--color-bg); + color: var(--color-accent); opacity: 1 !important; outline: initial; + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; + } } .modeButton.selected { border: 2px solid transparent; - background-color: var(--bg-mode-active); + background-color: var(--color-bg-higher); } .modeButton.preselected { - border: 2px solid var(--theme-info); - background-color: var(--bg-mode-active); + border: var(--border-style-accent); + background-color: var(--color-bg-higher); } .mode:not(.selected, .preselected) { - filter: var(--inactive-image-filter); - opacity: 0.6; + filter: grayscale(100%) brightness(50%); } diff --git a/app/components/MapPoolSelector.tsx b/app/components/MapPoolSelector.tsx index 7b832d5ae..e5e8b58e9 100644 --- a/app/components/MapPoolSelector.tsx +++ b/app/components/MapPoolSelector.tsx @@ -1,4 +1,5 @@ import clsx from "clsx"; +import { ArrowLeft, X } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Image } from "~/components/Image"; @@ -12,8 +13,6 @@ import { split, startsWith } from "~/utils/strings"; import { assertType } from "~/utils/types"; import { modeImageUrl, stageImageUrl } from "~/utils/urls"; import { SendouButton } from "./elements/Button"; -import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft"; -import { CrossIcon } from "./icons/Cross"; import styles from "./MapPoolSelector.module.css"; @@ -104,12 +103,10 @@ export function MapPoolSelector({ )}
    {allowBulkEdit && ( -
    - -
    + )} {info} handleStageClear(stageId)} - icon={} + icon={} variant="minimal" aria-label={t("common:actions.remove")} size="small" /> ) : ( handleStageAdd(stageId)} - icon={ - - } + icon={} variant="minimal" aria-label={t("common:actions.selectAll")} size="small" diff --git a/app/components/Markdown.tsx b/app/components/Markdown.tsx index 512a16534..8c13d91d7 100644 --- a/app/components/Markdown.tsx +++ b/app/components/Markdown.tsx @@ -13,7 +13,8 @@ export function Markdown({ children }: { children: string }) { .replace(/style\s*=\s*("[^"]*"|'[^']*')/gi, (_match, value) => { const sanitized = value.replace(CSS_URL_REGEX, ""); return `style=${sanitized}`; - }); + }) + .replace(/ +$/gm, ""); return ( ("closed"); + const previousPanelRef = React.useRef("closed"); + const user = useUser(); + const { unseenIds } = useNotifications(); + const chatContext = useChatContext(); + const layoutSize = useLayoutSize(); + + const hasUnseenNotifications = unseenIds.length > 0; + const hasFriendInSendouQ = + sidebarData?.friends.some((f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL) ?? + false; + + const skipAnimation = previousPanelRef.current !== "closed"; + + const closePanel = () => { + previousPanelRef.current = activePanel; + setActivePanel("closed"); + }; + + const handleTabPress = (panel: PanelType) => { + if (activePanel === panel) { + if (panel === "chat") { + chatContext?.setChatOpen(false); + } + previousPanelRef.current = activePanel; + setActivePanel("closed"); + return; + } + + if (activePanel === "chat") { + chatContext?.setChatOpen(false); + } + + if (panel === "chat") { + chatContext?.setChatOpen(true); + } + + previousPanelRef.current = activePanel; + setActivePanel(panel); + }; + + const closeChatPanel = () => { + chatContext?.setChatOpen(false); + closePanel(); + }; + + return ( +
    + {activePanel === "menu" ? ( + + ) : null} + + {activePanel === "friends" ? ( + + ) : null} + + {activePanel === "tourneys" ? ( + + ) : null} + + {activePanel === "you" ? ( + + ) : null} + + {chatContext?.chatOpen && layoutSize === "mobile" ? ( + + ) : null} + + +
    + ); +} + +function MobileTabBar({ + activePanel, + onTabPress, + isLoggedIn, + hasUnseenNotifications, + hasFriendInSendouQ, +}: { + activePanel: PanelType; + onTabPress: (panel: PanelType) => void; + isLoggedIn: boolean; + hasUnseenNotifications: boolean; + hasFriendInSendouQ: boolean; +}) { + const { t } = useTranslation(["front", "common"]); + const chatContext = useChatContext(); + + return ( + + ); +} + +function MobileTab({ + icon, + label, + isActive, + onPress, + showNotificationDot, + unreadCount, +}: { + icon: React.ReactNode; + label: string; + isActive: boolean; + onPress: () => void; + showNotificationDot?: boolean; + unreadCount?: number; +}) { + return ( + + ); +} + +function MobilePanel({ + title, + icon, + onClose, + children, + onTabPress, + isLoggedIn, + skipAnimation, +}: { + title: string; + icon: React.ReactNode; + onClose: () => void; + children: React.ReactNode; + onTabPress: (panel: PanelType) => void; + isLoggedIn: boolean; + skipAnimation: boolean; +}) { + return ( + + + +
    +
    {icon}
    +

    {title}

    + +
    +
    + {children} +
    + +
    +
    +
    + ); +} + +function MenuOverlay({ + streams, + savedTournamentIds, + onClose, + onTabPress, + isLoggedIn, + skipAnimation, +}: { + streams: NonNullable["streams"]; + savedTournamentIds?: number[]; + onClose: () => void; + onTabPress: (panel: PanelType) => void; + isLoggedIn: boolean; + skipAnimation: boolean; +}) { + const { t } = useTranslation(["front", "common"]); + const user = useUser(); + + return ( + + + +
    +
    + +
    +

    {t("front:mobileNav.menu")}

    +
    + {!user?.roles.includes("MINOR_SUPPORT") ? ( + } + variant="outlined" + > + {t("common:pages.support")} + + ) : null} + +
    +
    + + + +
    +
    +
    + +
    +

    + {t("front:sideNav.streams")} +

    +
    + {streams.length === 0 ? ( +
    + {t("front:sideNav.noStreams")} +
    + ) : null} +
      + +
    +
    + +
    +
    +
    + ); +} + +function FriendsPanel({ + friends, + onClose, + onTabPress, + isLoggedIn, + skipAnimation, +}: { + friends: NonNullable["friends"]; + onClose: () => void; + onTabPress: (panel: PanelType) => void; + isLoggedIn: boolean; + skipAnimation: boolean; +}) { + const { t } = useTranslation(["front", "common"]); + const user = useUser(); + + return ( + } + onClose={onClose} + onTabPress={onTabPress} + isLoggedIn={isLoggedIn} + skipAnimation={skipAnimation} + > + {friends.length > 0 ? ( + friends.map((friend) => ( + + )) + ) : ( +
    + {user + ? t("front:sideNav.friends.noFriends") + : t("front:sideNav.friends.notLoggedIn")} +
    + )} + + {t("common:actions.viewAll")} + + +
    + ); +} + +function TourneysPanel({ + events, + onClose, + onTabPress, + isLoggedIn, + skipAnimation, +}: { + events: NonNullable["events"]; + onClose: () => void; + onTabPress: (panel: PanelType) => void; + isLoggedIn: boolean; + skipAnimation: boolean; +}) { + const { t } = useTranslation(["front", "common"]); + + return ( + } + onClose={onClose} + onTabPress={onTabPress} + isLoggedIn={isLoggedIn} + skipAnimation={skipAnimation} + > + + + {t("common:actions.viewAll")} + + + + ); +} + +function YouPanel({ + onClose, + onTabPress, + isLoggedIn, + skipAnimation, +}: { + onClose: () => void; + onTabPress: (panel: PanelType) => void; + isLoggedIn: boolean; + skipAnimation: boolean; +}) { + const { t } = useTranslation(["front", "common"]); + const user = useUser(); + const { notifications, unseenIds } = useNotifications(); + + if (!user) { + return null; + } + + return ( + } + onClose={onClose} + onTabPress={onTabPress} + isLoggedIn={isLoggedIn} + skipAnimation={skipAnimation} + > +
    + + + {user.username} + + + + +
    + + {notifications ? ( + + ) : null} +
    + ); +} + +function ChatPanel({ + onClose, + onTabPress, + isLoggedIn, + skipAnimation, +}: { + onClose: () => void; + onTabPress: (panel: PanelType) => void; + isLoggedIn: boolean; + skipAnimation: boolean; +}) { + return ( + + + + + + + + + ); +} + +const LOGGED_IN_TABS: PanelType[] = [ + "menu", + "friends", + "tourneys", + "chat", + "you", +]; +const LOGGED_OUT_TABS: PanelType[] = ["menu"]; + +function GhostTabBar({ + onTabPress, + isLoggedIn, +}: { + onTabPress: (panel: PanelType) => void; + isLoggedIn: boolean; +}) { + const tabs = isLoggedIn ? LOGGED_IN_TABS : LOGGED_OUT_TABS; + + return ( + + ); +} diff --git a/app/components/NotificationDot.module.css b/app/components/NotificationDot.module.css new file mode 100644 index 000000000..6f40e48b9 --- /dev/null +++ b/app/components/NotificationDot.module.css @@ -0,0 +1,33 @@ +.dot { + position: absolute; + top: var(--dot-top, -2px); + right: var(--dot-right, -2px); + width: 8px; + height: 8px; + background-color: var(--color-text-accent); + border-radius: 100%; + outline: 2px solid var(--color-bg); + pointer-events: none; +} + +.pulse { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; + background-color: var(--color-text-accent); + box-shadow: 0 0 0 var(--color-text-accent); + animation: pulse 2s infinite; +} + +@keyframes pulse { + from { + box-shadow: 0 0 0 0 var(--color-text-accent); + } + 70% { + box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); + } + to { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + } +} diff --git a/app/components/NotificationDot.tsx b/app/components/NotificationDot.tsx new file mode 100644 index 000000000..ff0151a2f --- /dev/null +++ b/app/components/NotificationDot.tsx @@ -0,0 +1,10 @@ +import clsx from "clsx"; +import styles from "./NotificationDot.module.css"; + +export function NotificationDot({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/Pagination.module.css b/app/components/Pagination.module.css new file mode 100644 index 000000000..96f8aa809 --- /dev/null +++ b/app/components/Pagination.module.css @@ -0,0 +1,117 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + gap: var(--s-1); + background-color: var(--color-bg-high); + padding: var(--s-2) var(--s-3); + border-radius: var(--radius-full); + width: fit-content; + margin-inline: auto; +} + +.arrow { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + border-radius: var(--radius-full); + transition: opacity 0.15s ease; +} + +.arrow:hover:not(:disabled) { + opacity: 0.7; +} + +.arrow:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.page { + display: flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 2rem; + padding: 0 var(--s-1); + border: none; + background: transparent; + color: var(--color-text); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + cursor: pointer; + border-radius: var(--radius-full); + transition: background-color 0.15s ease; +} + +.page:hover:not(.pageActive) { + background-color: var(--color-bg-higher); +} + +.pageActive { + background-color: var(--color-text); + color: var(--color-text-inverse); +} + +.ellipsis { + display: flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 2rem; + color: var(--color-text); + font-size: var(--font-sm); +} + +.ellipsisButton { + border: none; + background: transparent; + cursor: pointer; + border-radius: var(--radius-full); + transition: background-color 0.15s ease; +} + +.ellipsisButton:hover { + background-color: var(--color-bg-higher); +} + +.jumpToForm { + display: flex; + align-items: center; + justify-content: center; +} + +.jumpToInput { + max-width: 2.5rem; + max-height: 1.75rem; + padding: 0 var(--s-1); + border: 2px solid var(--color-primary); + border-radius: var(--radius-sm); + background-color: var(--color-bg); + color: var(--color-text); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + text-align: center; +} + +.jumpToInput:focus { + outline: none; +} + +@container (width < 580px) { + .desktopOnly { + display: none; + } +} + +@container (width >= 580px) { + .mobileOnly { + display: none; + } +} diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx index be5ed64fa..79463f0eb 100644 --- a/app/components/Pagination.tsx +++ b/app/components/Pagination.tsx @@ -1,8 +1,7 @@ import clsx from "clsx"; -import { SendouButton } from "~/components/elements/Button"; -import { ArrowLeftIcon } from "~/components/icons/ArrowLeft"; -import { ArrowRightIcon } from "~/components/icons/ArrowRight"; -import { nullFilledArray } from "~/utils/arrays"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import styles from "./Pagination.module.css"; export function Pagination({ currentPage, @@ -17,39 +16,223 @@ export function Pagination({ previousPage: () => void; setPage: (page: number) => void; }) { + const pages = getPageNumbers(currentPage, pagesCount); + const [jumpToIndex, setJumpToIndex] = React.useState(null); + return ( -
    - } - variant="outlined" - className="fix-rtl" - isDisabled={currentPage === 1} - onPress={previousPage} +
    + + {pages.map((page, index) => + page.value === "..." ? ( + setJumpToIndex(index)} + onClose={() => setJumpToIndex(null)} + onJump={(page) => { + setPage(page); + setJumpToIndex(null); + }} /> - ))} -
    -
    - {currentPage}/{pagesCount} -
    - } - variant="outlined" - className="fix-rtl" - isDisabled={currentPage === pagesCount} - onPress={nextPage} + ) : ( + + ), + )} +
    ); } + +function JumpToEllipsis({ + isOpen, + pagesCount, + desktopOnly, + mobileOnly, + onOpen, + onClose, + onJump, +}: { + isOpen: boolean; + pagesCount: number; + desktopOnly?: boolean; + mobileOnly?: boolean; + onOpen: () => void; + onClose: () => void; + onJump: (page: number) => void; +}) { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const value = formData.get("page") as string; + if (!value) { + onClose(); + return; + } + + const pageNumber = Number(value); + if (Number.isNaN(pageNumber) || pageNumber < 1 || pageNumber > pagesCount) { + onClose(); + return; + } + + onJump(pageNumber); + }; + + const handleBlur = () => { + onClose(); + }; + + if (isOpen) { + return ( +
    + +
    + ); + } + + return ( + + ); +} + +function PageButton({ + page, + currentPage, + desktopOnly, + setPage, +}: { + page: number; + currentPage: number; + desktopOnly?: boolean; + setPage: (page: number) => void; +}) { + return ( + + ); +} + +type PageItem = { + value: number | "..."; + desktopOnly?: boolean; + mobileOnly?: boolean; +}; + +function getPageNumbers(currentPage: number, pagesCount: number): PageItem[] { + if (pagesCount <= 5) { + return Array.from({ length: pagesCount }, (_, i) => ({ value: i + 1 })); + } + + if (pagesCount <= 9) { + return Array.from({ length: pagesCount }, (_, i) => ({ + value: i + 1, + desktopOnly: i >= 2 && i < pagesCount - 2, + })); + } + + const mobileStart = Math.max(2, currentPage - 1); + const mobileEnd = Math.min(pagesCount - 1, currentPage + 1); + const desktopStart = Math.max(2, currentPage - 2); + const desktopEnd = Math.min(pagesCount - 1, currentPage + 2); + + const isMobileVisible = (page: number) => + page >= mobileStart && page <= mobileEnd; + const isDesktopVisible = (page: number) => + page >= desktopStart && page <= desktopEnd; + + const pages: PageItem[] = []; + + pages.push({ value: 1 }); + + const needsDesktopEllipsisStart = desktopStart > 2; + const needsMobileEllipsisStart = mobileStart > 2; + + if (needsDesktopEllipsisStart && needsMobileEllipsisStart) { + pages.push({ value: "..." }); + } else if (needsMobileEllipsisStart) { + pages.push({ value: "...", mobileOnly: true }); + } else if (needsDesktopEllipsisStart) { + pages.push({ value: "...", desktopOnly: true }); + } + + for (let i = 2; i <= pagesCount - 1; i++) { + if (isDesktopVisible(i)) { + pages.push({ value: i, desktopOnly: !isMobileVisible(i) }); + } + } + + const needsDesktopEllipsisEnd = desktopEnd < pagesCount - 1; + const needsMobileEllipsisEnd = mobileEnd < pagesCount - 1; + + if (needsDesktopEllipsisEnd && needsMobileEllipsisEnd) { + pages.push({ value: "..." }); + } else if (needsMobileEllipsisEnd) { + pages.push({ value: "...", mobileOnly: true }); + } else if (needsDesktopEllipsisEnd) { + pages.push({ value: "...", desktopOnly: true }); + } + + pages.push({ value: pagesCount }); + + return pages; +} diff --git a/app/components/RequiredHiddenInput.module.css b/app/components/RequiredHiddenInput.module.css new file mode 100644 index 000000000..f99cb2609 --- /dev/null +++ b/app/components/RequiredHiddenInput.module.css @@ -0,0 +1,8 @@ +.input { + position: absolute; + width: 0; + height: 0; + border: none; + opacity: 0; + pointer-events: none; +} diff --git a/app/components/RequiredHiddenInput.tsx b/app/components/RequiredHiddenInput.tsx index 259535c82..9b02afbc7 100644 --- a/app/components/RequiredHiddenInput.tsx +++ b/app/components/RequiredHiddenInput.tsx @@ -1,3 +1,5 @@ +import styles from "./RequiredHiddenInput.module.css"; + export function RequiredHiddenInput({ value, isValid, @@ -9,7 +11,7 @@ export function RequiredHiddenInput({ }) { return ( div { + padding: var(--s-2); + border-radius: var(--radius-box); + background-color: var(--color-bg); + } + + & > h2 { + color: var(--color-text-high); + font-size: var(--font-md); + } +} diff --git a/app/components/Section.tsx b/app/components/Section.tsx index 2e4e6bfd3..eb121e319 100644 --- a/app/components/Section.tsx +++ b/app/components/Section.tsx @@ -1,3 +1,5 @@ +import styles from "./Section.module.css"; + export function Section({ title, children, @@ -8,7 +10,7 @@ export function Section({ className?: string; }) { return ( -
    +
    {title &&

    {title}

    }
    {children}
    diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css new file mode 100644 index 000000000..d2b56e725 --- /dev/null +++ b/app/components/SideNav.module.css @@ -0,0 +1,299 @@ +.sideNav { + background-color: var(--color-bg-nav); + min-width: var(--layout-sidenav-width); + max-width: var(--layout-sidenav-width); + border-right: 1.5px solid var(--color-border); + overflow: hidden; + position: sticky; + top: 0; + left: 0; + height: 100dvh; + display: none; + flex-direction: column; + + @media screen and (min-width: 1000px) { + display: flex; + } +} + +.sideNavCollapsed { + display: none; +} + +.sideNavTop { + height: var(--layout-nav-height); + background-color: var(--color-bg-nav); + border-bottom: 1.5px solid var(--color-border); + display: flex; + align-items: center; + padding-inline: var(--s-2); + flex-shrink: 0; + overflow: hidden; +} + +.sideNavTopCentered { + justify-content: center; +} + +.sideNavInner { + display: flex; + flex-direction: column; + gap: var(--s-2); + padding: var(--s-1-5); + padding-block-end: var(--s-2); + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.sideNavFooter { + height: var(--layout-nav-height); + display: flex; + align-items: center; + gap: var(--s-1); + padding: var(--s-1-5) var(--s-3); + background-color: var(--color-bg-nav); + border-top: 1.5px solid var(--color-border); + flex-shrink: 0; +} + +.sideNavFooterUser { + display: flex; + align-items: center; + gap: var(--s-2); + color: var(--color-text); + text-decoration: none; + font-size: var(--font-xs); + font-weight: var(--weight-semi); + border-radius: var(--radius-field); + padding: var(--s-1); + margin-left: calc(-1 * var(--s-1)); + flex: 1; + min-width: 0; +} + +.sideNavFooterUser:hover { + background-color: var(--color-bg-high); +} + +.sideNavFooterUsername { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sideNavFooterActions { + display: flex; + align-items: center; + gap: var(--s-0-5); + margin-left: auto; +} + +.sideNavFooterButton { + display: grid; + place-items: center; + background-color: transparent; + width: var(--field-size-sm); + height: var(--field-size-sm); + padding: 0; + border: none; + border-radius: var(--radius-field); + color: var(--color-text-high); + cursor: pointer; +} + +.sideNavFooterButton:hover { + background-color: var(--color-bg-high); + color: var(--color-text); +} + +.sideNavFooterButton:focus-visible { + outline: var(--focus-ring); + outline-offset: -2px; +} + +.sideNavFooterButton svg { + width: 18px; + height: 18px; +} + +.sideNavFooterNotification { + position: relative; +} + +.sideNavFooterUnseenDot { + --dot-top: 2px; + --dot-right: 2px; +} + +.sideNavHeader { + color: var(--color-text-high); + padding: var(--s-1-5) var(--s-2); + margin-inline: calc(-1 * var(--s-1-5)); + background-color: var(--color-bg-high); + display: flex; + align-items: center; + gap: var(--s-2); + border-color: var(--color-border); + border-top: 1.5px solid var(--color-border); + border-bottom: 1.5px solid var(--color-border); + height: var(--layout-nav-height); +} + +.sideNavHeader:first-child { + margin-block-start: calc(-1 * var(--s-1-5)); + border-top: none; +} + +.sideNavHeader h2 { + font-size: var(--font-xs); + font-weight: var(--weight-bold); +} + +.sideNavHeaderAction { + margin-inline-start: auto; + font-size: var(--font-2xs); + font-weight: var(--weight-normal); +} + +.sideNavHeaderAction a { + color: var(--color-text-high); + text-decoration: none; +} + +.sideNavHeaderAction a:hover { + color: var(--color-text); + text-decoration: underline; +} + +.sideNavHeaderClose { + margin-inline-start: auto; + margin-inline-end: var(--s-1); +} + +.sideNavHeader svg { + width: 18px; +} + +.iconContainer { + background-color: var(--color-bg-high); + border-radius: var(--radius-field); + padding: var(--s-1-5); +} + +.listLink { + font-size: var(--font-xs); + color: var(--color-text); + text-decoration: none; + padding: var(--s-1) var(--s-2); + border-radius: var(--radius-field); + transition: + background-color 0.15s, + color 0.15s; + display: flex; + align-items: center; + gap: var(--s-2); + + &:hover { + &:not(:has(.listLinkSubtitle)) { + color: var(--color-text); + } + background-color: var(--color-bg-high); + } + + &[aria-current="page"] { + color: var(--color-text); + background-color: var(--color-bg-higher); + font-weight: var(--weight-bold); + } +} + +.listButton { + composes: listLink; + background: none; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + + &:focus-visible { + outline: var(--focus-ring); + } +} + +.listLinkImageContainer { + position: relative; + flex-shrink: 0; +} + +.listLinkImage { + width: 32px; + height: 32px; + border-radius: var(--radius-avatar); + object-fit: cover; + flex-shrink: 0; +} + +.listLinkOverlayIcon { + position: absolute; + bottom: -2px; + right: -2px; + width: 16px; + height: 16px; + border-radius: var(--radius-field); + background-color: var(--color-bg); + padding: 2px; +} + +.listLinkContent { + display: flex; + flex-direction: column; + min-width: 0; + gap: var(--s-0-5); + width: 100%; +} + +.listLinkTitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:has(+ .listLinkSubtitle) { + color: var(--color-text); + } +} + +.listLinkSubtitleRow { + display: flex; + align-items: center; + width: 100%; + color: var(--color-text-high); +} + +.listLinkSubtitle { + font-size: var(--font-2xs); + color: var(--color-text-high); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.listLinkBadge { + display: flex; + align-items: center; + margin-left: auto; + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + color: var(--color-text-inverse); + background-color: var(--color-text-accent); + padding: 0 var(--s-1); + border-radius: var(--radius-selector); + height: var(--selector-size-xs); + text-align: center; + flex-shrink: 0; + text-transform: uppercase; +} + +.listLinkBadgeWarning { + background-color: var(--color-text-second); +} diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx new file mode 100644 index 000000000..ba79f2f7c --- /dev/null +++ b/app/components/SideNav.tsx @@ -0,0 +1,214 @@ +import clsx from "clsx"; +import { X } from "lucide-react"; +import type * as React from "react"; +import { Button } from "react-aria-components"; +import { Link } from "react-router"; +import { SendouButton } from "~/components/elements/Button"; +import type { Tables } from "~/db/tables"; +import { Avatar } from "./Avatar"; +import styles from "./SideNav.module.css"; + +export function SideNav({ + children, + className, + footer, + top, + topCentered, + collapsed, +}: { + children: React.ReactNode; + className?: string; + footer?: React.ReactNode; + top?: React.ReactNode; + topCentered?: boolean; + collapsed?: boolean; +}) { + return ( + + ); +} + +export function SideNavHeader({ + children, + icon, + showClose, + action, +}: { + children: React.ReactNode; + icon?: React.ReactNode; + showClose?: boolean; + action?: React.ReactNode; +}) { + return ( +
    + {icon ?
    {icon}
    : null} +

    {children}

    + {action ? ( + {action} + ) : null} + {showClose ? ( + } + variant="minimal" + slot="close" + className={styles.sideNavHeaderClose} + /> + ) : null} +
    + ); +} + +function ListItemContent({ + children, + user, + imageUrl, + overlayIconUrl, + subtitle, + badge, + badgeVariant, + suppressSubtitleHydrationWarning, +}: { + children: React.ReactNode; + user?: Pick; + imageUrl?: string; + overlayIconUrl?: string; + subtitle?: React.ReactNode; + badge?: React.ReactNode; + badgeVariant?: "default" | "warning"; + suppressSubtitleHydrationWarning?: boolean; +}) { + return ( + <> + {user ? ( + + ) : imageUrl ? ( +
    + + {overlayIconUrl ? ( + + ) : null} +
    + ) : null} +
    + {children} + {subtitle || badge ? ( +
    + {subtitle ? ( + + {subtitle} + + ) : null} + {typeof badge === "string" ? ( + + {badge} + + ) : ( + badge + )} +
    + ) : null} +
    + + ); +} + +export function ListLink({ + children, + to, + onClick, + isActive, + imageUrl, + overlayIconUrl, + user, + subtitle, + badge, + badgeVariant, +}: { + children: React.ReactNode; + to: string; + onClick?: (event: React.MouseEvent) => void; + isActive?: boolean; + imageUrl?: string; + overlayIconUrl?: string; + user?: Pick; + subtitle?: React.ReactNode; + badge?: React.ReactNode; + badgeVariant?: "default" | "warning"; +}) { + return ( + + + {children} + + + ); +} + +export function ListButton({ + children, + user, + subtitle, + badge, + badgeVariant, +}: { + children: React.ReactNode; + user?: Pick; + subtitle?: string | null; + badge?: string | null; + badgeVariant?: "default" | "warning"; +}) { + return ( + + ); +} + +export function SideNavFooter({ children }: { children: React.ReactNode }) { + return
    {children}
    ; +} diff --git a/app/components/StageSelect.module.css b/app/components/StageSelect.module.css index 8a2ed2ebb..ddfe69cb7 100644 --- a/app/components/StageSelect.module.css +++ b/app/components/StageSelect.module.css @@ -1,7 +1,3 @@ -.selectWidthWider { - --select-width: 250px; -} - .item { display: flex; gap: var(--s-2); @@ -9,7 +5,7 @@ } .stageImg { - border-radius: var(--rounded-sm); + border-radius: var(--radius-field); } .stageLabel { diff --git a/app/components/StageSelect.tsx b/app/components/StageSelect.tsx index 96bdbb78f..a25dddc68 100644 --- a/app/components/StageSelect.tsx +++ b/app/components/StageSelect.tsx @@ -48,8 +48,6 @@ export function StageSelect({ search={{ placeholder: t("common:forms.stageSearch.search.placeholder"), }} - className={styles.selectWidthWider} - popoverClassName={styles.selectWidthWider} selectedKey={isControlled ? value : undefined} defaultSelectedKey={isControlled ? undefined : (initialValue as Key)} onSelectionChange={handleOnChange} diff --git a/app/components/StreamListItems.module.css b/app/components/StreamListItems.module.css new file mode 100644 index 000000000..41173499b --- /dev/null +++ b/app/components/StreamListItems.module.css @@ -0,0 +1,58 @@ +.xpSubtitle { + display: flex; + align-items: center; + gap: 2px; +} + +.xpIcon { + width: 14px; + height: 14px; +} + +.tierBadge { + flex-shrink: 0; +} + +.upcomingDivider { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-1) var(--s-2); + font-size: var(--font-3xs); + color: var(--color-text-high); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badgeRow { + display: flex; + align-items: center; + gap: var(--s-1); + margin-left: auto; + flex-shrink: 0; +} + +.saveIconButton { + display: grid; + place-items: center; + background: none; + border: none; + cursor: pointer; + color: var(--color-text-high); + padding: 0; + height: var(--selector-size-sm); + aspect-ratio: 1 / 1; + border-radius: var(--radius-selector); +} + +.saveIconButton:hover { + color: var(--color-text); + background-color: var(--color-bg-higher); +} + +.upcomingDivider::before, +.upcomingDivider::after { + content: ""; + flex: 1; + border-top: 1px solid var(--color-border); +} diff --git a/app/components/StreamListItems.tsx b/app/components/StreamListItems.tsx new file mode 100644 index 000000000..9b60b5a8e --- /dev/null +++ b/app/components/StreamListItems.tsx @@ -0,0 +1,181 @@ +import { isToday, isTomorrow } from "date-fns"; +import { Bookmark, BookmarkCheck } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useFetcher } from "react-router"; +import type { SidebarStream } from "~/features/core/streams/streams.server"; +import type { LanguageCode } from "~/modules/i18n/config"; +import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates"; +import { navIconUrl, tournamentRegisterPage } from "~/utils/urls"; +import { Image } from "./Image"; +import { ListLink } from "./SideNav"; +import styles from "./StreamListItems.module.css"; +import { TierPill } from "./TierPill"; + +export function StreamListItems({ + streams, + onClick, + isLoggedIn, + savedTournamentIds, +}: { + streams: SidebarStream[]; + onClick?: () => void; + isLoggedIn?: boolean; + savedTournamentIds?: number[]; +}) { + const { t, i18n } = useTranslation(["front"]); + + const formatRelativeDate = (timestamp: number) => { + const date = new Date(timestamp * 1000); + const timeStr = date.toLocaleTimeString(i18n.language, { + hour: "numeric", + minute: "2-digit", + }); + + if (isToday(date)) { + const rtf = new Intl.RelativeTimeFormat(i18n.language, { + numeric: "auto", + }); + const dayStr = rtf.format(0, "day"); + return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`; + } + if (isTomorrow(date)) { + const rtf = new Intl.RelativeTimeFormat(i18n.language, { + numeric: "auto", + }); + const dayStr = rtf.format(1, "day"); + return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`; + } + + return date.toLocaleDateString(i18n.language, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + }; + + return ( + <> + {streams.map((stream, i) => { + const startsAtDate = databaseTimestampToDate(stream.startsAt); + const isUpcoming = startsAtDate.getTime() > Date.now(); + const prevStream = streams.at(i - 1); + const prevIsLive = + prevStream && + databaseTimestampToDate(prevStream.startsAt).getTime() <= Date.now(); + const showUpcomingDivider = isUpcoming && prevIsLive; + const tournamentId = stream.id.startsWith("upcoming-") + ? Number(stream.id.replace("upcoming-", "")) + : null; + + return ( + + {showUpcomingDivider ? ( +
    + {t("front:sideNav.streams.upcoming")} +
    + ) : null} + + + {stream.peakXp} + + ) : stream.subtitle ? ( + stream.subtitle + ) : isUpcoming ? ( + formatRelativeDate(stream.startsAt) + ) : ( + formatDistanceToNow(startsAtDate, { + addSuffix: true, + language: i18n.language as LanguageCode, + }) + ) + } + badge={ + !isUpcoming ? ( + "LIVE" + ) : ( +
    + {isLoggedIn && tournamentId !== null ? ( + + ) : null} + {streamTierBadge(stream)} +
    + ) + } + onClick={onClick} + > + {stream.name} +
    +
    + ); + })} + + ); +} + +function SaveTournamentStreamButton({ + tournamentId, + isSaved, +}: { + tournamentId: number; + isSaved: boolean; +}) { + const fetcher = useFetcher(); + + const optimisticSaved = + fetcher.formData?.get("_action") === "SAVE_TOURNAMENT" + ? true + : fetcher.formData?.get("_action") === "UNSAVE_TOURNAMENT" + ? false + : isSaved; + + const Icon = optimisticSaved ? BookmarkCheck : Bookmark; + + return ( + e.stopPropagation()} + > + + + + + ); +} + +function streamTierBadge(stream: SidebarStream): React.ReactNode { + const tier = stream.tier ?? stream.tentativeTier; + if (!tier) return undefined; + + return ( +
    + +
    + ); +} diff --git a/app/components/SubNav.module.css b/app/components/SubNav.module.css new file mode 100644 index 000000000..ce6064ae2 --- /dev/null +++ b/app/components/SubNav.module.css @@ -0,0 +1,90 @@ +.container { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--s-4); + margin-block-end: var(--s-8); +} + +.secondary { + gap: var(--s-1); + margin-block-end: 0; +} + +.linkContainer { + display: flex; + max-width: 110px; + flex: 1; + flex-direction: column; + align-items: center; + color: var(--color-text); + gap: var(--s-1-5); +} + +.linkContainer.active { + color: var(--color-text-accent); +} + +.secondary .linkContainer { + max-width: none; + flex: none; +} + +.secondary .linkContainer.active { + color: var(--color-text); +} + +.link { + width: 100%; + padding: 0 var(--s-4); + height: var(--field-size-sm); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-field); + font-weight: var(--weight-semi); + background-color: var(--color-bg-high); + cursor: pointer; + font-size: var(--font-xs); + text-align: center; + white-space: nowrap; +} + +.linkSecondary { + height: var(--selector-size-sm); + border-radius: var(--radius-selector); + padding: 0 var(--s-2); + font-size: var(--font-xs); + color: var(--color-text-high); + background-color: transparent; +} + +.secondary .linkContainer.active .linkSecondary { + background-color: var(--color-bg-higher); + color: var(--color-text); +} + +.container.compact .link { + padding: var(--s-1) var(--s-2); +} + +.borderGuy { + width: 78%; + height: 3px; + border-radius: var(--radius-box); + background-color: var(--color-bg-higher); + visibility: hidden; +} + +.borderGuySecondary { + height: 2.5px; + background-color: var(--color-bg-high); +} + +.linkContainer.active > .borderGuy { + visibility: initial; +} + +.secondary .borderGuy { + display: none; +} diff --git a/app/components/SubNav.tsx b/app/components/SubNav.tsx index 23c43b113..b0600a5c6 100644 --- a/app/components/SubNav.tsx +++ b/app/components/SubNav.tsx @@ -2,6 +2,7 @@ import clsx from "clsx"; import type * as React from "react"; import type { LinkProps } from "react-router"; import { NavLink } from "react-router"; +import styles from "./SubNav.module.css"; export function SubNav({ children, @@ -13,8 +14,8 @@ export function SubNav({ return (