diff --git a/app/Community/Controllers/ReorderSiteAwardsController.php b/app/Community/Controllers/ReorderSiteAwardsController.php new file mode 100644 index 0000000000..22e400f336 --- /dev/null +++ b/app/Community/Controllers/ReorderSiteAwardsController.php @@ -0,0 +1,294 @@ +getUsersSiteAwards(request()->user()); + $cleanAwards = $this->SeparateAwards($awards); + + return Inertia::render('reorder-site-awards') + ->with('awards', $cleanAwards); + } + + public function getUsersSiteAwards(User $user): array + { + $bindings = [ + 'userId' => $user->id, + 'userId2' => $user->id, + 'userId3' => $user->id, + ]; + + $gameAwardValues = implode("','", AwardType::gameValues()); + + $query = " + -- game awards (mastery, beaten) + SELECT " . unixTimestampStatement('saw.awarded_at', 'AwardedAt') . ", saw.award_type, saw.user_id, saw.award_key, saw.award_tier, saw.order_column, gd.title AS Title, s.id AS ConsoleID, s.name AS ConsoleName, NULL AS Flags, gd.image_icon_asset_path AS ImageIcon, NULL AS display_award_tier + FROM user_awards AS saw + LEFT JOIN games AS gd ON ( gd.id = saw.award_key AND saw.award_type IN ('{$gameAwardValues}') ) + LEFT JOIN systems AS s ON s.id = gd.system_id + WHERE + saw.award_type IN('{$gameAwardValues}') + AND saw.user_id = :userId + GROUP BY saw.award_type, saw.award_key, saw.award_tier + HAVING + -- Remove duplicate game beaten awards. + (saw.award_type != '" . AwardType::GameBeaten->value . "' OR saw.award_tier = 1 OR NOT EXISTS ( + SELECT 1 FROM user_awards AS saw2 + WHERE saw2.award_type = saw.award_type AND saw2.award_key = saw.award_key AND saw2.award_tier = 1 AND saw2.user_id = saw.user_id + )) + -- Remove duplicate mastery awards. + AND (saw.award_type != '" . AwardType::Mastery->value . "' OR saw.award_tier = 1 OR NOT EXISTS ( + SELECT 1 FROM user_awards AS saw3 + WHERE saw3.award_type = saw.award_type AND saw3.award_key = saw.award_key AND saw3.award_tier = 1 AND saw3.user_id = saw.user_id + )) + UNION + -- event awards + SELECT " . unixTimestampStatement('saw.awarded_at', 'AwardedAt') . ", saw.award_type, saw.user_id, saw.award_key, saw.award_tier, saw.order_column, gd.title AS Title, " . System::Events . ", 'Events', NULL, e.image_asset_path AS ImageIcon, saw.display_award_tier + FROM user_awards AS saw + LEFT JOIN events e ON e.id = saw.award_key + LEFT JOIN games gd ON gd.id = e.legacy_game_id + WHERE + saw.award_type = '" . AwardType::Event->value . "' + AND saw.user_id = :userId3 + UNION + -- non-game awards (developer contribution, ...) + SELECT " . unixTimestampStatement('MAX(saw.awarded_at)', 'AwardedAt') . ", saw.award_type, saw.user_id, MAX( saw.award_key ), saw.award_tier, saw.order_column, NULL, NULL, NULL, NULL, NULL, NULL + FROM user_awards AS saw + WHERE + saw.award_type NOT IN('{$gameAwardValues}','" . AwardType::Event->value . "') + AND saw.user_id = :userId2 + GROUP BY saw.award_type + ORDER BY order_column, AwardedAt, award_type, award_tier ASC"; + + // TODO: Don't use legacy + $dbResult = legacyDbFetchAll($query, $bindings)->toArray(); + + foreach ($dbResult as &$award) { + // dd($dbResult); + + unset($award['user_id']); + + $award['AwardType'] = AwardType::from($award['award_type']); + $award['AwardData'] = (int) $award['award_key']; + $award['AwardDataExtra'] = (int) $award['award_tier']; + $award['DisplayOrder'] = (int) $award['order_column']; + + if ($award['ConsoleID']) { + $award['ConsoleID'] = (int) $award['ConsoleID']; + } + + unset($award['award_type'], $award['award_key'], $award['award_tier'], $award['order_column']); + } + + return $dbResult; + } + + /* + * array of ["AwardType" => enum AwardType, AwardData => int, AwardDataExtra => int, DisplayOrder => int, ConsoleID => int?] + */ + /** + * Parses awards into a usable state for the frontend. + * + * @param array $userAwards array of awards from the database. + * @return array + */ + public function SeparateAwards(array $userAwards): array + { + $awardEventGameIds = []; + $awardEventIds = []; + foreach ($userAwards as $award) { + $type = $award['AwardType']; + if ($type === AwardType::Event) { + $awardEventIds[] = (int) $award['AwardData']; + } elseif ($award['ConsoleName'] === 'Events' && AwardType::isGame($type)) { + $awardEventGameIds[] = (int) $award['AwardData']; + } + } + + if (! empty($awardEventGameIds)) { + $awardEventIds = array_merge($awardEventIds, + Event::whereIn('legacy_game_id', $awardEventIds)->select('id')->pluck('id')->toArray() + ); + } + + $eventData = new Collection; + $eventAwardData = new Collection; + if (! empty($awardEventIds)) { + $eventData = Event::whereIn('id', $awardEventIds)->with('legacyGame')->get()->keyBy('id'); + $eventAwardData = EventAward::whereIn('event_id', $awardEventIds)->get()->groupBy('event_id'); + } + + $gameAwards = []; // Mastery awards that aren't Events. + $eventAwards = []; // Event awards and Events mastery awards. + $siteAwards = []; // Dev event awards and non-game active awards. + + /** @var UserAwardData[] $awards */ + $awards = []; + + foreach ($userAwards as $award) { + $type = $award['AwardType']; + $id = (int) $award['AwardData']; + $extra = (int) $award['AwardDataExtra']; + $awardDate = $award['AwardedAt']; + + $section = 'unknown'; + + if (AwardType::isGame($type)) { + if ($award['ConsoleName'] === 'Events') { + $section = 'event'; + } elseif ($type !== AwardType::GameBeaten) { + $section = 'game'; + $award["ImageIcon"] = asset($award['ImageIcon']); + $gameId = $id; + + $award["IsGold"] = $extra === 1; + } + } elseif ($type === AwardType::Event) { + if ($eventData[$id]?->gives_site_award) { + $section = 'site'; + } else { + $section = 'event'; + } + + $event = $eventData->find($id); + if ($event) { + $tooltipTitle = $event->title; + $tooltipDescription = "Awarded for completing this event"; + $image = $event->image_asset_path; + + // Use the display preference for the badge image, but always + // use the actual earned tier for the tooltip text. Otherwise, + // it's very ambiguous what tier the player is actually on if + // they have a saved tier preference. + $displayTier = (int) ($award['display_award_tier'] ?? $extra); + $actualTier = $extra; + + $tierIndicesToFetch = array_unique([$displayTier, $actualTier]); + $eventAwardsByTier = ($eventAwardData->get($id) ?? collect()) + ->whereIn('tier_index', $tierIndicesToFetch) + ->keyBy('tier_index'); + + $displayEventAward = $eventAwardsByTier->get($displayTier); + if ($displayEventAward) { + $image = $displayEventAward->image_asset_path; + } + + $actualEventAward = $eventAwardsByTier->get($actualTier); + if ($actualEventAward && $actualEventAward->points_required < $event->legacyGame->points_total) { + // Strip the event/game title prefix from the tier label to avoid duplication. + $tierLabel = $actualEventAward->label; + $gameTitle = $event->legacyGame->title ?? ''; + if ($tierLabel !== $gameTitle && str_starts_with($tierLabel, $gameTitle)) { + $tierLabel = ltrim(substr($tierLabel, strlen($gameTitle)), ' -:'); + } + + // Only append the tier label if the event title doesn't already contain it. + if (! str_ends_with($event->title, $tierLabel)) { + $tooltipTitle = "$event->title - $tierLabel"; + } + + $tooltipDescription = "Awarded for earning at least $actualEventAward->points_required points"; + } + + $award["Tooltip"] = $tooltipTitle . "
" . $tooltipDescription; + $award["ImageIcon"] = media_asset($image); + $award["IsGold"] = true; + $award["Link"] = route('event.show', $event->id); + /*
$tooltip

{$awardDate}

*/ + // tooltip: "", + } + + } elseif (AwardType::isActive($type)) { + $this->makeTooltip($award); + $section = 'site'; + } + + if ($section === 'unknown') { + continue; + } + + $newAward = new UserAwardData( + title: $award["Title"], + imageUrl: $award['ImageIcon'] ?? '', + tooltip: $award['Tooltip'] ?? '', + link: $award['Link'] ?? '', + isGold: $award['IsGold'] ?? false, + gameId: $gameId ?? null, + dateAwarded: $award['AwardedAt'] . "", + awardType: $award['AwardType'], + awardSection: $section, + displayOrder: $award['DisplayOrder'] + ); + + $awards[] = $newAward; + } + + return $awards; + } + + public function makeTooltip(&$award): void + { + switch ($award['AwardType']) { + case AwardType::GameBeaten: + case AwardType::Mastery: + // no tooltip needed, frontend uses GameAvatar + $award['Tooltip'] = null; + + return; + case AwardType::AchievementPointsYield: + $data = $award["AwardData"]; + $points = PlayerBadge::getBadgeThreshold(AwardType::AchievementPointsYield, $data); + $award['Title'] = "Achievement Points Earned by Others"; + $award['Tooltip'] = "Awarded for producing many valuable achievements, providing over $points points to the community!"; + $award["ImageIcon"] = asset("/assets/images/badge/contribPoints-$data.png"); + $award["IsGold"] = true; + + return; + case AwardType::AchievementUnlocksYield: + $data = $award["AwardData"]; + $points = PlayerBadge::getBadgeThreshold(AwardType::AchievementUnlocksYield, $data); + $award["Title"] = "Achievements Earned by Others"; + $award['Tooltip'] = "Awarded for being a hard-working developer and producing achievements that have been earned over $points times!"; + $award["ImageIcon"] = asset("/assets/images/badge/contribYield-$data.png"); + $award["IsGold"] = true; + + return; + + case AwardType::CertifiedLegend: + $award["Title"] = "Certified Legend"; + $award['Tooltip'] = 'Specially Awarded to a Certified RetroAchievements Legend'; + $award["ImageIcon"] = asset('/assets/images/badge/legend.png'); + $award["IsGold"] = true; + + return; + case AwardType::PatreonSupporter: + $award["Title"] = "Patreon Supporter"; + $award['Tooltip'] = 'Awarded for being a Patreon supporter! Thank-you so much for your support!'; + $award["ImageIcon"] = asset('/assets/images/badge/patreon.png'); + $award["IsGold"] = true; + $award["Link"] = route('patreon-supporter.index'); + + return; + case AwardType::Event: + $award['Tooltip'] = 'Event Award'; + + return; + } + } +} diff --git a/app/Community/Data/UserAwardData.php b/app/Community/Data/UserAwardData.php new file mode 100644 index 0000000000..68fd6406ec --- /dev/null +++ b/app/Community/Data/UserAwardData.php @@ -0,0 +1,24 @@ +name('message-thread.user.create'); Route::get('settings', [UserSettingsController::class, 'show'])->name('settings.show'); + + Route::get('reorder-site-awards', [ReorderSiteAwardsController::class, 'index'])->name('reorder-site-awards.index'); }); }); diff --git a/lang/en_US.json b/lang/en_US.json index 50f7637cfe..e543eab490 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -1309,6 +1309,7 @@ "Save": "Save", "Saving...": "Saving...", "Saved!": "Saved!", + "Save All Changes": "Save All Changes", "<1>If subsets aren't working or if every subset still requires a patch, make sure you're using the latest version of your emulator. If you're using RetroArch, make absolutely sure the emulator version is 1.22.1 or higher. Updating the RetroArch cores alone is not sufficient.": "<1>If subsets aren't working or if every subset still requires a patch, make sure you're using the latest version of your emulator. If you're using RetroArch, make absolutely sure the emulator version is 1.22.1 or higher. Updating the RetroArch cores alone is not sufficient.", "Opted In": "Opted In", "Opted Out": "Opted Out", diff --git a/package.json b/package.json index f750179b25..89ea38bbb4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "@bbob/plugin-helper": "^4.2.0", "@bbob/preset-react": "^4.2.0", "@bbob/react": "^4.2.0", + "@dnd-kit/helpers": "^0.3.2", + "@dnd-kit/react": "^0.3.2", "@floating-ui/core": "^1.6.8", "@floating-ui/dom": "^1.5.1", "@hookform/resolvers": "^5.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2179508fa1..dbff7fa856 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: '@bbob/react': specifier: ^4.2.0 version: 4.2.0(react@19.2.0) + '@dnd-kit/helpers': + specifier: ^0.3.2 + version: 0.3.2 + '@dnd-kit/react': + specifier: ^0.3.2 + version: 0.3.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@floating-ui/core': specifier: ^1.6.8 version: 1.6.8 @@ -600,6 +606,30 @@ packages: peerDependencies: postcss-selector-parser: ^6.0.13 + '@dnd-kit/abstract@0.3.2': + resolution: {integrity: sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q==} + + '@dnd-kit/collision@0.3.2': + resolution: {integrity: sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA==} + + '@dnd-kit/dom@0.3.2': + resolution: {integrity: sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg==} + + '@dnd-kit/geometry@0.3.2': + resolution: {integrity: sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w==} + + '@dnd-kit/helpers@0.3.2': + resolution: {integrity: sha512-pj7pCE6BiysNetpPnzb3BJOrcKiqueUr1LFg6wYoi2fIFYpz66n2Ojd7HTwfwkpv0oyC3QlvA6Dk8cOmi6VavA==} + + '@dnd-kit/react@0.3.2': + resolution: {integrity: sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.3.2': + resolution: {integrity: sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A==} + '@emnapi/core@1.4.4': resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==} @@ -1124,6 +1154,9 @@ packages: '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + '@preact/signals-core@1.13.0': + resolution: {integrity: sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg==} + '@prisma/instrumentation@7.2.0': resolution: {integrity: sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==} peerDependencies: @@ -1902,67 +1935,56 @@ packages: resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.50.1': resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.50.1': resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.50.1': resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.50.1': resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.50.1': resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.50.1': resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.50.1': resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.50.1': resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.50.1': resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.50.1': resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.50.1': resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} @@ -2163,28 +2185,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.17': resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.17': resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.17': resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.17': resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} @@ -2618,49 +2636,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -4309,28 +4319,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -6360,6 +6366,50 @@ snapshots: dependencies: postcss-selector-parser: 6.1.2 + '@dnd-kit/abstract@0.3.2': + dependencies: + '@dnd-kit/geometry': 0.3.2 + '@dnd-kit/state': 0.3.2 + tslib: 2.8.0 + + '@dnd-kit/collision@0.3.2': + dependencies: + '@dnd-kit/abstract': 0.3.2 + '@dnd-kit/geometry': 0.3.2 + tslib: 2.8.0 + + '@dnd-kit/dom@0.3.2': + dependencies: + '@dnd-kit/abstract': 0.3.2 + '@dnd-kit/collision': 0.3.2 + '@dnd-kit/geometry': 0.3.2 + '@dnd-kit/state': 0.3.2 + tslib: 2.8.0 + + '@dnd-kit/geometry@0.3.2': + dependencies: + '@dnd-kit/state': 0.3.2 + tslib: 2.8.0 + + '@dnd-kit/helpers@0.3.2': + dependencies: + '@dnd-kit/abstract': 0.3.2 + tslib: 2.8.0 + + '@dnd-kit/react@0.3.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/abstract': 0.3.2 + '@dnd-kit/dom': 0.3.2 + '@dnd-kit/state': 0.3.2 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.0 + + '@dnd-kit/state@0.3.2': + dependencies: + '@preact/signals-core': 1.13.0 + tslib: 2.8.0 + '@emnapi/core@1.4.4': dependencies: '@emnapi/wasi-threads': 1.0.3 @@ -6888,6 +6938,8 @@ snapshots: '@polka/url@1.0.0-next.28': {} + '@preact/signals-core@1.13.0': {} + '@prisma/instrumentation@7.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 diff --git a/resources/js/common/components/UserAward/UserAward.tsx b/resources/js/common/components/UserAward/UserAward.tsx new file mode 100644 index 0000000000..bf5e7b9679 --- /dev/null +++ b/resources/js/common/components/UserAward/UserAward.tsx @@ -0,0 +1,29 @@ +import type { FC } from 'react'; +import UserAwardData = App.Community.Data.UserAwardData; + +export const UserAward: FC<{ award: UserAwardData; size?: number }> = ({ award, size = 64 }) => { + const img = ( + {award.tooltip} + ); + + /* +
$tooltip

{$awardDate}

+ */ + + return ( +
+ {award.link ? {img} : img} +
+ ); +}; diff --git a/resources/js/common/components/UserAward/index.ts b/resources/js/common/components/UserAward/index.ts new file mode 100644 index 0000000000..fcf78b00c3 --- /dev/null +++ b/resources/js/common/components/UserAward/index.ts @@ -0,0 +1 @@ +export * from './UserAward'; diff --git a/resources/js/common/components/UserAwardCounter/UserAwardCounter.tsx b/resources/js/common/components/UserAwardCounter/UserAwardCounter.tsx new file mode 100644 index 0000000000..bb9dc318ba --- /dev/null +++ b/resources/js/common/components/UserAwardCounter/UserAwardCounter.tsx @@ -0,0 +1,38 @@ +import type { FC } from 'react'; + +type CounterProps = { + icon: string; + text: string; + numItems: number; + numHidden?: number; +}; + +/* +function RenderCounter(string $icon, string $text, int $numItems, int $numHidden): string +{ + $tooltip = "$numItems $text"; + if ($numHidden > 0) { + $tooltip .= " ($numHidden hidden)"; + } + $counter = + "
+
$icon
$numItems
+
"; + + return $counter; +} + */ + +export const UserAwardCounter: FC = ({ icon, text, numItems, numHidden = 0 }) => { + let tooltip = `${numItems} ${text}`; + if (numHidden > 0) { + tooltip += ' ($numHidden hidden)'; + } + + return ( +
+
{icon}
+
{numItems}
+
+ ); +}; diff --git a/resources/js/common/components/UserAwardList/UserAwardList.tsx b/resources/js/common/components/UserAwardList/UserAwardList.tsx new file mode 100644 index 0000000000..7a4b2034c2 --- /dev/null +++ b/resources/js/common/components/UserAwardList/UserAwardList.tsx @@ -0,0 +1,26 @@ +import type { FC } from 'react'; +import React from 'react'; + +type UserAwardListProps = { + headingLabel: string; + headingCountSlot: React.ReactNode; + awards: React.ReactNode[]; +}; + +export const UserAwardList: FC = ({ + headingLabel, + headingCountSlot, + awards, +}) => { + return ( +
+

+ {headingLabel} + {headingCountSlot} +

+
+ {awards.map((award) => award)} +
+
+ ); +}; diff --git a/resources/js/features/reorder-site-awards/components/+root/ReorderSiteAwardsMainRoot.tsx b/resources/js/features/reorder-site-awards/components/+root/ReorderSiteAwardsMainRoot.tsx new file mode 100644 index 0000000000..65ff1f17b5 --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/+root/ReorderSiteAwardsMainRoot.tsx @@ -0,0 +1,70 @@ +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { usePageProps } from '@/common/hooks/usePageProps'; +import { AwardOrderTableOld } from '@/features/reorder-site-awards/components/AwardOrderTableOld'; + +import { ResetOrderButton } from '../ResetOrderButton'; +import UserAwardData = App.Community.Data.UserAwardData; + +export const ReorderSiteAwardsMainRoot: FC = () => { + const { awards } = usePageProps<{ + awards: UserAwardData[]; + }>(); + const { t } = useTranslation(); + + const saveAllChangesButton = () => { + const mappedTableRows = reorderSiteAwards.collectMappedTableRows(); + + try { + const withComputedDisplayOrderValues = + reorderSiteAwards.computeDisplayOrderValues(mappedTableRows); + + postAllAwardsDisplayOrder(withComputedDisplayOrderValues); + reorderSiteAwards.moveHiddenRowsToTop(); + } catch (error) { + showStatusFailure(error.toString()); + } + }; + + return ( +
+

{t('Reorder Site Awards')}

+ +
+

+ To rearrange your site awards, drag and drop the award rows or use the buttons within each + row to move them up or down. Award categories can be reordered using the dropdown menus + next to each category name. Remember to save your changes before leaving by clicking the + "Save All Changes" button. +

+
+ +
+
+ + +
+ +
+ + +
+ ); +}; diff --git a/resources/js/features/reorder-site-awards/components/+root/index.ts b/resources/js/features/reorder-site-awards/components/+root/index.ts new file mode 100644 index 0000000000..abeeab141c --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/+root/index.ts @@ -0,0 +1 @@ +export * from './ReorderSiteAwardsMainRoot'; diff --git a/resources/js/features/reorder-site-awards/components/+sidebar/ReorderSiteAwardsSidebarRoot.tsx b/resources/js/features/reorder-site-awards/components/+sidebar/ReorderSiteAwardsSidebarRoot.tsx new file mode 100644 index 0000000000..c9ca67306c --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/+sidebar/ReorderSiteAwardsSidebarRoot.tsx @@ -0,0 +1,86 @@ +import { GameAvatar } from '@/common/components/GameAvatar'; +import { UserAward } from '@/common/components/UserAward'; +import { UserAwardCounter } from '@/common/components/UserAwardCounter/UserAwardCounter'; +import { UserAwardList } from '@/common/components/UserAwardList/UserAwardList'; +import { usePageProps } from '@/common/hooks/usePageProps'; +import UserAwardData = App.Community.Data.UserAwardData; + +export const ReorderSiteAwardsSidebarRoot = () => { + const { awards } = usePageProps<{ + awards: UserAwardData[]; + }>(); + + console.log('awards', awards); + + const gameAwardAwards = awards + .sort((award) => award.displayOrder) + .filter((award) => award.awardSection === 'game') + .map((award) => ( + + )); + + const masteries = awards.filter((award) => award.isGold).length; + const gameAwardList = ( + + + + + } + awards={gameAwardAwards} + /> + ); + + const siteAwardAwards = awards + .sort((award) => award.displayOrder) + .filter((award) => award.awardSection === 'site') + .map((award) => ); + + const siteAwardList = ( + + } + awards={siteAwardAwards} + /> + ); + + const eventAwardAwards = awards + .sort((award) => award.displayOrder) + .filter((award) => award.awardSection === 'event') + .map((award) => ); + + const eventAwardList = ( + + } + awards={eventAwardAwards} + /> + ); + + return ( +
+ {gameAwardList} + {siteAwardList} + {eventAwardList} +
+ ); +}; diff --git a/resources/js/features/reorder-site-awards/components/+sidebar/index.ts b/resources/js/features/reorder-site-awards/components/+sidebar/index.ts new file mode 100644 index 0000000000..d75ed1bdea --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/+sidebar/index.ts @@ -0,0 +1 @@ +export * from './ReorderSiteAwardsSidebarRoot'; diff --git a/resources/js/features/reorder-site-awards/components/AwardOrderTable/AwardOrderTable.tsx b/resources/js/features/reorder-site-awards/components/AwardOrderTable/AwardOrderTable.tsx new file mode 100644 index 0000000000..2fbb5529de --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/AwardOrderTable/AwardOrderTable.tsx @@ -0,0 +1,120 @@ +import type { FC } from 'react'; +import { useRef, useState } from 'react'; +import UserAwardData = App.Community.Data.UserAwardData; +import { move } from '@dnd-kit/helpers'; +import { DragDropProvider } from '@dnd-kit/react'; +import { useSortable } from '@dnd-kit/react/sortable'; +import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; + +type Column = { id: string; name: string }; + +const columns: Column[] = [ + { id: 'imageUrl', name: 'Badge' }, + { id: 'title', name: 'Site Award' }, + { id: 'hidden', name: 'Hidden' }, + { id: 'manualMove', name: 'Manual Move' }, +]; +// https://tanstack.com/table/latest/docs/framework/react/examples/row-dnd?panel=sandbox +export const AwardOrderTable: FC<{ awards: UserAwardData[] }> = ({ awards }) => { + 'use no memo'; // useReactTable does not support React Compiler + + const [data, setData] = useState(awards); + + // eslint-disable-next-line react-hooks/incompatible-library -- https://github.com/TanStack/table/issues/5567 + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const initialOrder = useRef({ + columns, + data, + }); + + return ( + { + initialOrder.current = { + columns, + data, + }; + }} + onDragOver={(event) => { + const { source } = event.operation; + + setData((rows) => move(rows, event)); + }} + onDragEnd={(event) => { + if (event.canceled) { + // setData(initialOrder.current.rows); + } + }} + > +
+ + + + + ))} + + + + {data.map((row, index) => ( + + ))} + +
+ {columns.map((column, index) => ( + {column.name}
+
+
+ ); +}; + +interface SortableRowProps { + row: UserAwardData; + columns: Column[]; + index: number; + lastRow?: boolean; +} + +function SortableRow({ row, columns, index, lastRow }: SortableRowProps) { + const { ref, handleRef, isDragging } = useSortable({ + id: row.gameId, + index, + type: 'row', + accept: 'row', + }); + + return ( + + True + {columns.map((column) => ( + True + ))} + + ); +} diff --git a/resources/js/features/reorder-site-awards/components/AwardOrderTableOld/AwardOrderTableOld.tsx b/resources/js/features/reorder-site-awards/components/AwardOrderTableOld/AwardOrderTableOld.tsx new file mode 100644 index 0000000000..f865532da0 --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/AwardOrderTableOld/AwardOrderTableOld.tsx @@ -0,0 +1,239 @@ +import React, { useRef, useState } from 'react'; + +import { ManualMoveButtons } from '@/features/reorder-site-awards/components/ManualMoveButtons'; +import UserAwardData = App.Community.Data.UserAwardData; +import { DragDropProvider } from '@dnd-kit/react'; +import { useTranslation } from 'react-i18next'; + +import { UserAward } from '@/common/components/UserAward'; + +interface AwardOrderTableProps { + title: string; + awards: UserAwardData[]; + awardCounterStart: number; + renderedSectionCount: number; + prefersSeeingSavedHiddenRows: boolean; + initialSectionOrder: number; + reorderSiteAwards: any; // your drag + checkbox handlers +} + +const initialColumns: { id: string; name: string }[] = [ + { id: 'imageUrl', name: 'Badge' }, + { id: 'title', name: 'Site Award' }, + { id: 'hidden', name: 'Hidden' }, + { id: 'manualMove', name: 'Manual Move' }, +]; + +export const AwardOrderTableOld: React.FC = ({ + title, + awards, + awardCounterStart, + renderedSectionCount, + prefersSeeingSavedHiddenRows, + initialSectionOrder, + reorderSiteAwards, +}) => { + const humanReadableAwardKind = title.split(' ')[0].toLowerCase(); + + const { t } = useTranslation(); + + let awardCounter = awardCounterStart; + + const renderAwardTitle = (award: UserAwardData) => { + switch (award.awardType) { + case 'mastery': // Mastery (replace with enum if desired) + return {award.awardSection}; + + case 'achievement_unlocks_yield': + return 'Achievements Earned by Others'; + + case 'achievement_points_yield': + return 'Achievement Points Earned by Others'; + + case 'patreon_supporter': + return 'Patreon Supporter'; + + case 'certified_legend': + return 'Certified Legend'; + + default: + return award.tooltip; + } + }; + + const [rows, setRows] = useState(awards); + const [columns, setColumns] = useState(initialColumns); + const initialOrder = useRef({ + columns, + rows, + }); + + return ( + +
+

{title}

+ + +
+ + + + + + + + + + + + + + {awards.map((award, index) => { + const awardDisplayOrder = award.displayOrder; + const isHiddenPreChecked = awardDisplayOrder === -1; + + const subduedOpacityClassName = isHiddenPreChecked ? 'opacity-40' : ''; + + const cursorGrabClass = isHiddenPreChecked ? '' : 'cursor-grab'; + + const savedHiddenClass = isHiddenPreChecked ? 'saved-hidden' : ''; + + const hiddenClass = !prefersSeeingSavedHiddenRows && isHiddenPreChecked ? 'hidden' : ''; + + const rowClassNames = ` + award-table-row + select-none + transition + ${cursorGrabClass} + ${savedHiddenClass} + ${hiddenClass} + `; + + const currentCounter = awardCounter++; + + return ( + + {/* Badge */} + + + {/* Title */} + + + {/* Hidden checkbox */} + + + {/* Manual Move */} + + + {/* Hidden inputs */} + + {/**/} + {/**/} + + ); + })} + +
BadgeSite AwardHidden + Manual Move +
+ + + {renderAwardTitle(award)} + + false + // reorderSiteAwards.handleRowHiddenCheckedChange(e, currentCounter) + } + /> + +
+ {awards.length > 50 && ( + <> + + + + + )} + + {awards.length > 15 && awards.length <= 50 && ( + <> + + + + )} + + {awards.length <= 15 && ( + + )} +
+
+
+ ); +}; diff --git a/resources/js/features/reorder-site-awards/components/AwardOrderTableOld/index.ts b/resources/js/features/reorder-site-awards/components/AwardOrderTableOld/index.ts new file mode 100644 index 0000000000..e23f908f93 --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/AwardOrderTableOld/index.ts @@ -0,0 +1 @@ +export * from './AwardOrderTableOld'; diff --git a/resources/js/features/reorder-site-awards/components/ManualMoveButtons/ManualMoveButtons.tsx b/resources/js/features/reorder-site-awards/components/ManualMoveButtons/ManualMoveButtons.tsx new file mode 100644 index 0000000000..91f6d29a31 --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/ManualMoveButtons/ManualMoveButtons.tsx @@ -0,0 +1,65 @@ +import type { FC } from 'react'; +import UserAwardData = App.Community.Data.UserAwardData; +import { useTranslation } from 'react-i18next'; + +interface ManualMoveButtonsProps { + award: UserAwardData; + awardCounter: number; + moveValue: number; + upLabel?: string; + downLabel?: string; + autoScroll?: boolean; + orientation?: 'vertical' | 'horizontal'; + isHiddenPreChecked?: boolean; +} + +export const ManualMoveButtons: FC = ({ + award, + awardCounter, + isHiddenPreChecked, + moveValue, + autoScroll, + upLabel, + downLabel, + orientation, +}) => { + const { t } = useTranslation(); + + const downValue = moveValue; + const upValue = moveValue * -1; + + const containerClassNames = orientation === 'vertical' ? 'flex flex-col' : 'flex'; + + const rowsPlural = moveValue === 1 ? 'row' : 'rows'; + let upA11yLabel = `Move up ${moveValue} ${rowsPlural}`; + let downA11yLabel = `Move down ${moveValue} ${rowsPlural}`; + + if (moveValue > 10000) { + upA11yLabel = 'Move to top'; + downA11yLabel = 'Move to bottom'; + } + + return ( +
+ + + +
+ ); +}; diff --git a/resources/js/features/reorder-site-awards/components/ManualMoveButtons/index.ts b/resources/js/features/reorder-site-awards/components/ManualMoveButtons/index.ts new file mode 100644 index 0000000000..f25f4f68a7 --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/ManualMoveButtons/index.ts @@ -0,0 +1 @@ +export * from './ManualMoveButtons'; diff --git a/resources/js/features/reorder-site-awards/components/ResetOrderButton/ResetOrderButton.tsx b/resources/js/features/reorder-site-awards/components/ResetOrderButton/ResetOrderButton.tsx new file mode 100644 index 0000000000..797a160b24 --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/ResetOrderButton/ResetOrderButton.tsx @@ -0,0 +1,42 @@ +export const ResetOrderButton = () => { + return ( + + ); +}; + +export function handleResetOrder(): void { + if ( + !confirm( + 'This will resort all your awards by the date they were earned (oldest first). You can preview the changes before saving.', + ) + ) { + return; + } + + const rows = Array.from(document.querySelectorAll('.award-table-row')); + + // Sort rows by date of aquisition (ascending) + sortAwardsByAwardDate(rows); + + for (const row of rows) { + const awardKind = row.getAttribute('data-award-kind'); + document + .querySelector(`#${awardKind}-reorder-table`) + ?.querySelector('tbody') + ?.appendChild(row); + } +} + +const sortAwardsByAwardDate = (awards: Array): void => { + awards.sort((a, b) => { + const dateA = a.getAttribute('data-award-date') ?? '0'; + const dateB = b.getAttribute('data-award-date') ?? '0'; + + const numA = parseInt(dateA, 10); + const numB = parseInt(dateB, 10); + + return numA - numB; + }); +}; diff --git a/resources/js/features/reorder-site-awards/components/ResetOrderButton/index.ts b/resources/js/features/reorder-site-awards/components/ResetOrderButton/index.ts new file mode 100644 index 0000000000..16bd0fec22 --- /dev/null +++ b/resources/js/features/reorder-site-awards/components/ResetOrderButton/index.ts @@ -0,0 +1 @@ +export * from './ResetOrderButton'; diff --git a/resources/js/pages/reorder-site-awards.tsx b/resources/js/pages/reorder-site-awards.tsx new file mode 100644 index 0000000000..af8d929717 --- /dev/null +++ b/resources/js/pages/reorder-site-awards.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; + +import { SEO } from '@/common/components/SEO'; +import { AppLayout } from '@/common/layouts/AppLayout'; +import type { AppPage } from '@/common/models'; +import { ReorderSiteAwardsMainRoot } from '@/features/reorder-site-awards/components/+root'; +import { ReorderSiteAwardsSidebarRoot } from '@/features/reorder-site-awards/components/+sidebar'; + +const ReorderSiteAwards: AppPage = () => { + const { t } = useTranslation(); + + return ( + <> + + + + + + + + + + + ); +}; + +ReorderSiteAwards.layout = (page) => {page}; + +export default ReorderSiteAwards; diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 45f3b0f65a..5b51671b29 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -166,6 +166,18 @@ declare namespace App.Community.Data { descriptionParams: Record | null; undoToken: string | null; }; + export type UserAwardData = { + title: string; + imageUrl: string; + tooltip: string; + link: string | null; + isGold: boolean; + gameId: number | null; + dateAwarded: string; + awardType: App.Community.Enums.AwardType; + awardSection: string; + displayOrder: number; + }; export type UserGameListPageProps = { paginatedGameListEntries: App.Data.PaginatedData; filterableSystemOptions: Array;