Add "Discover all the features" to the front page

This commit is contained in:
Kalle 2026-03-20 17:36:30 +02:00
parent 570da9541b
commit a1d2784a9e
20 changed files with 200 additions and 36 deletions

View File

@ -1,4 +1,5 @@
import clsx from "clsx";
import type { TFunction } from "i18next";
import { Search } from "lucide-react";
import * as React from "react";
import {
@ -18,6 +19,7 @@ import { Avatar } from "~/components/Avatar";
import { Image } from "~/components/Image";
import { Input } from "~/components/Input";
import type { SearchLoaderData } from "~/features/search/routes/search";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
mySlugify,
navIconUrl,
@ -83,6 +85,7 @@ export function GlobalSearch() {
const searchParamOpen = searchParams.get("search") === "open";
const searchParamType = searchParams.get("type");
const searchParamWeapon = searchParams.get("weapon");
const initialSearchType =
searchParamType && SEARCH_TYPES.includes(searchParamType as SearchType)
? (searchParamType as SearchType)
@ -91,10 +94,10 @@ export function GlobalSearch() {
const [isOpen, setIsOpen] = React.useState(searchParamOpen);
React.useEffect(() => {
if (searchParamOpen && !isOpen) {
if (searchParamOpen) {
setIsOpen(true);
}
}, [searchParamOpen, isOpen]);
}, [searchParamOpen]);
React.useEffect(() => {
setIsMac(/Mac|iPhone|iPad|iPod/.test(navigator.userAgent));
@ -115,10 +118,11 @@ export function GlobalSearch() {
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open && (searchParamOpen || searchParamType)) {
if (!open && (searchParamOpen || searchParamType || searchParamWeapon)) {
const newParams = new URLSearchParams(searchParams);
newParams.delete("search");
newParams.delete("type");
newParams.delete("weapon");
setSearchParams(newParams, { replace: true });
}
};
@ -138,8 +142,9 @@ export function GlobalSearch() {
<Modal className={styles.modal}>
<Dialog className={styles.dialog} aria-label={t("common:search")}>
<GlobalSearchContent
onClose={() => handleOpenChange(false)}
onClose={() => setIsOpen(false)}
initialSearchType={initialSearchType}
initialWeaponId={searchParamWeapon}
/>
</Dialog>
</Modal>
@ -148,12 +153,26 @@ export function GlobalSearch() {
);
}
function resolveInitialWeapon(
weaponIdStr: string | null,
t: TFunction<["common", "weapons"]>,
): SelectedWeapon | null {
if (!weaponIdStr) return null;
const id = Number(weaponIdStr) as MainWeaponId;
if (Number.isNaN(id)) return null;
const name = t(`weapons:MAIN_${id}`);
if (!name || name === `MAIN_${id}`) return null;
return { id, name, slug: mySlugify(name) };
}
function GlobalSearchContent({
onClose,
initialSearchType,
initialWeaponId,
}: {
onClose: () => void;
initialSearchType: SearchType | null;
initialWeaponId: string | null;
}) {
const { t } = useTranslation(["common", "weapons"]);
const navigate = useNavigate();
@ -162,7 +181,9 @@ function GlobalSearchContent({
initialSearchType ?? getInitialSearchType(),
);
const [selectedWeapon, setSelectedWeapon] =
React.useState<SelectedWeapon | null>(null);
React.useState<SelectedWeapon | null>(
resolveInitialWeapon(initialWeaponId, t),
);
const inputRef = React.useRef<HTMLInputElement>(null);
const listBoxRef = React.useRef<HTMLDivElement>(null);

View File

@ -1,9 +1,11 @@
import cachified from "@epic-web/cachified";
import type { Tables } from "~/db/tables";
import { getUser } from "~/features/auth/core/user.server";
import * as Changelog from "~/features/front-page/core/Changelog.server";
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import type { SerializeFrom } from "~/utils/remix";
@ -13,26 +15,35 @@ import * as ShowcaseTournaments from "../core/ShowcaseTournaments.server";
export type FrontPageLoaderData = SerializeFrom<typeof loader>;
export const loader = async () => {
const [tournaments, changelog, leaderboards, rotations] = await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(null),
cachified({
key: "front-changelog",
cache,
ttl: ttl(IN_MILLISECONDS.ONE_HOUR),
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
return Changelog.get();
},
}),
cachedLeaderboards(),
SplatoonRotationRepository.findAll(),
]);
const user = getUser();
const [tournaments, changelog, leaderboards, rotations, weaponPool] =
await Promise.all([
ShowcaseTournaments.categorizedTournamentsByUserId(null),
cachified({
key: "front-changelog",
cache,
ttl: ttl(IN_MILLISECONDS.ONE_HOUR),
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
return Changelog.get();
},
}),
cachedLeaderboards(),
SplatoonRotationRepository.findAll(),
user
? QSettingsRepository.settingsByUserId(user.id).then(
(s) => s.qWeaponPool ?? null,
)
: Promise.resolve(null),
]);
return {
tournaments,
changelog,
leaderboards,
rotations,
weaponPool,
};
};

View File

@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { Image } from "~/components/Image";
import { Image, WeaponImage } from "~/components/Image";
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
import { BSKYLikeIcon } from "~/components/icons/BSKYLike";
import { BSKYReplyIcon } from "~/components/icons/BSKYReply";
import { BSKYRepostIcon } from "~/components/icons/BSKYRepost";
import { ExternalIcon } from "~/components/icons/External";
import { navItems } from "~/components/layout/nav-items";
import { Main } from "~/components/Main";
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
@ -41,6 +42,7 @@ export default function FrontPage() {
<SeasonBanner />
<SplatoonRotations />
<ResultHighlights />
<DiscoverFeatures />
<ChangelogList />
</Main>
);
@ -227,6 +229,61 @@ function Leaderboard({
);
}
const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
function DiscoverFeatures() {
const { t } = useTranslation(["front", "common"]);
const data = useLoaderData<typeof loader>();
const filteredNavItems = navItems.filter(
(item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name),
);
return (
<div className="stack md">
<Divider smallText className="text-uppercase text-xs font-bold">
{t("front:discover.header")}
</Divider>
{data.weaponPool && data.weaponPool.length > 0 ? (
<div className={styles.weaponPills}>
{data.weaponPool.map((weapon) => (
<Link
key={weapon.weaponSplId}
to={`?search=open&type=weapons&weapon=${weapon.weaponSplId}`}
className={styles.weaponPill}
>
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant="badge"
size={32}
/>
</Link>
))}
</div>
) : null}
<nav className={styles.discoverGrid}>
{filteredNavItems.map((item) => (
<Link
key={item.name}
to={`/${item.url}`}
className={styles.discoverGridItem}
>
<div className={styles.discoverGridItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
))}
</nav>
</div>
);
}
function ChangelogList() {
const { t } = useTranslation(["front"]);
const data = useLoaderData<typeof loader>();

View File

@ -165,3 +165,62 @@
gap: var(--s-2);
flex-wrap: wrap;
}
.weaponPills {
display: flex;
gap: var(--s-2);
justify-content: center;
flex-wrap: wrap;
}
.weaponPill {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-bg-higher);
border-radius: var(--radius-full);
padding: var(--s-1);
transition: background-color 0.15s ease-out;
}
.weaponPill:hover {
background-color: var(--color-bg-high);
}
.discoverGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--s-3);
padding: var(--s-2);
}
@media (min-width: 600px) {
.discoverGrid {
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
}
}
.discoverGridItem {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
text-decoration: none;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-align: center;
}
.discoverGridItem:hover .discoverGridItemImage {
background-color: var(--color-bg-high);
}
.discoverGridItemImage {
width: var(--field-size-lg);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
background-color: var(--color-bg-higher);
display: grid;
place-items: center;
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "Now",
"rotations.nextLabel": "Next",
"rotations.credit": "Data from splatoon3.ink",
"rotations.filter.all": "All"
"rotations.filter.all": "All",
"discover.header": "Discover all the features"
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}

View File

@ -38,5 +38,6 @@
"rotations.current": "",
"rotations.nextLabel": "",
"rotations.credit": "",
"rotations.filter.all": ""
"rotations.filter.all": "",
"discover.header": ""
}