Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,135 @@
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { newArtPage } from "~/features/art/art-urls";
import { useUser } from "~/features/auth/core/user";
import { lfgNewPostPage } from "~/features/lfg/lfg-urls";
import { plusSuggestionsNewPage } from "~/features/plus-suggestions/plus-suggestions-urls";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { userNewBuildPage } from "~/features/user-page/user-page-urls";
import { newVodPage } from "~/features/vods/vods-urls";
import {
CALENDAR_NEW_PAGE,
NEW_TEAM_PAGE,
NEW_TROPHY_PAGE,
navIconUrl,
newAssociationsPage,
newScrimPostPage,
ORGANIZATION_NEW_PAGE,
TOURNAMENT_NEW_PAGE,
} from "~/utils/urls";
import { SendouButton } from "../elements/Button";
import {
SendouMenu,
SendouMenuItem,
type SendouMenuItemProps,
} from "../elements/Menu";
export function AnythingAdder({ compact }: { compact?: boolean }) {
const { t } = useTranslation(["common"]);
const user = useUser();
if (!user) {
return null;
}
const items: Array<SendouMenuItemProps> = [
{
id: "tournament",
children: t("header.adder.tournament"),
imagePath: navIconUrl("medal"),
href: TOURNAMENT_NEW_PAGE,
},
{
id: "organization",
children: t("header.adder.organization"),
imagePath: navIconUrl("medal"),
href: ORGANIZATION_NEW_PAGE,
},
{
id: "calendarEvent",
children: t("header.adder.calendarEvent"),
imagePath: navIconUrl("calendar"),
href: CALENDAR_NEW_PAGE,
},
{
id: "builds",
children: t("header.adder.build"),
imagePath: navIconUrl("builds"),
href: userNewBuildPage(user),
},
{
id: "team",
children: t("header.adder.team"),
imagePath: navIconUrl("t"),
href: NEW_TEAM_PAGE,
},
{
id: "scrimPost",
children: t("header.adder.scrimPost"),
imagePath: navIconUrl("scrims"),
href: newScrimPostPage(),
},
{
id: "association",
children: t("header.adder.association"),
imagePath: navIconUrl("associations"),
href: newAssociationsPage(),
},
{
id: "lfgPost",
children: t("header.adder.lfgPost"),
imagePath: navIconUrl("lfg"),
href: lfgNewPostPage(),
},
{
id: "art",
children: t("header.adder.art"),
imagePath: navIconUrl("art"),
href: newArtPage(),
},
{
id: "vods",
children: t("header.adder.vod"),
imagePath: navIconUrl("vods"),
href: newVodPage(),
},
{
id: "plus",
children: t("header.adder.plusSuggestion"),
imagePath: navIconUrl("plus"),
href: plusSuggestionsNewPage(),
},
canAccessTrophies(user)
? {
id: "trophy",
children: t("header.adder.trophy"),
imagePath: navIconUrl("trophies"),
href: NEW_TROPHY_PAGE,
}
: null,
].filter((item) => item !== null);
return (
<SendouMenu
placement="bottom right"
trigger={
<SendouButton
size="small"
icon={<Plus />}
shape={compact ? "square" : undefined}
data-testid="anything-adder-menu-button"
>
{compact ? undefined : `${t("common:actions.addNew")}…`}
</SendouButton>
}
>
{items.map((item) => (
<SendouMenuItem
key={item.id}
data-testid={`menu-item-${item.id}`}
{...item}
/>
))}
</SendouMenu>
);
}

View File

@@ -0,0 +1,65 @@
import type { TFunction } from "i18next";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { SendouDialog } from "~/components/elements/Dialog";
import { useUser } from "~/features/auth/core/user";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParam } from "~/modules/search-params/hooks";
import { SENDOU_INK_DISCORD_URL } from "~/utils/urls";
import { authErrorSearchParams } from "./layout-search-params";
import styles from "./UserItem.module.css";
export function AuthErrorDialog() {
const { t } = useTranslation();
const isHydrated = useHydrated();
const user = useUser();
const [authError] = useSearchParam(authErrorSearchParams, "authError");
if (authError == null || !isHydrated || user) return null;
return createPortal(
<SendouDialog
isDismissable
onCloseTo="/"
heading={
authError === "aborted"
? t("auth.errors.aborted")
: t("auth.errors.failed")
}
>
<div className={`stack md ${styles.userItem}`}>
{authErrorContent(authError, t)}
</div>
</SendouDialog>,
document.body,
);
}
function authErrorContent(authError: string, t: TFunction) {
switch (authError) {
case "aborted":
return t("auth.errors.discordPermissions");
case "discordOverloaded":
return (
<>
{t("auth.errors.discordOverloaded")}{" "}
<a href={SENDOU_INK_DISCORD_URL} target="_blank" rel="noreferrer">
{SENDOU_INK_DISCORD_URL}
</a>
</>
);
case "unverifiedEmail":
return t("auth.errors.unverifiedEmail");
case "browserPrivacy":
return t("auth.errors.browserPrivacy");
default:
return (
<>
{t("auth.errors.unknown")}{" "}
<a href={SENDOU_INK_DISCORD_URL} target="_blank" rel="noreferrer">
{SENDOU_INK_DISCORD_URL}
</a>
</>
);
}
}

View File

@@ -0,0 +1,290 @@
.sidebar {
display: flex;
flex-direction: column;
height: 100%;
max-height: var(--visual-viewport-height, 100dvh);
overflow: hidden;
}
.sidebarHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-4);
background-color: var(--color-bg-high);
border-bottom: 1.5px solid var(--color-border);
flex-shrink: 0;
color: var(--color-text-high);
min-height: var(--layout-nav-height);
& h2 {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
}
@media screen and (display-mode: standalone) and (max-width: 599px) {
.sidebarHeader,
.chatHeader {
padding-block-start: env(safe-area-inset-top);
}
.sidebar {
padding-block-end: env(safe-area-inset-bottom);
}
}
.closeButton {
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-error);
height: var(--field-size);
aspect-ratio: 1 / 1;
padding: 0;
border-radius: var(--radius-field);
margin-inline-start: auto;
@media screen and (min-width: 600px) {
height: var(--field-size-sm);
}
&:hover {
background-color: var(--color-bg-higher);
}
}
.roomList {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
padding: var(--s-1-5);
overflow-y: auto;
flex: 1;
min-height: 0;
}
.roomItem {
padding: var(--s-2);
}
.roomName {
font-weight: var(--weight-semi);
}
.roomTimestamp {
font-size: var(--font-2xs);
color: var(--color-text-high);
flex-shrink: 0;
}
.unreadBadge {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
border-radius: 9px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.chatHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-2);
border-bottom: 1.5px solid var(--color-border);
background-color: var(--color-bg-high);
flex-shrink: 0;
min-height: var(--layout-nav-height);
}
.backButton {
position: relative;
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-text);
height: var(--field-size-sm);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
&:hover {
background-color: var(--color-bg-higher);
}
}
.backButtonBadge {
position: absolute;
top: -2px;
right: -2px;
font-size: var(--font-3xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
display: grid;
place-items: center;
line-height: 1;
}
.chatHeaderInfo {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.chatHeaderLink {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1-5);
overflow: hidden;
min-width: 0;
text-decoration: none;
color: inherit;
border-radius: var(--radius-field);
transition: background-color 0.15s;
&:is(a):hover {
background-color: var(--color-bg-higher);
}
}
.chatHeaderTitle {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chatHeaderSubtitle {
font-size: var(--font-2xs);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
padding: var(--s-4);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-xs);
}
.splitView {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.splitPanel {
flex-grow: var(--split-grow, 1);
flex-shrink: 1;
flex-basis: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
& + & {
border-top: 1.5px solid var(--color-border);
}
}
.splitPanelHeader {
flex-shrink: 0;
padding: var(--s-1-5) var(--s-3);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-high);
background-color: var(--color-bg-high);
}
.chatContainer {
flex: 1;
min-height: 0;
overflow: hidden;
padding-block-start: var(--s-2);
/* Chat section.container - fill the space */
& > section {
height: 100%;
display: flex;
flex-direction: column;
}
/* inputContainer wraps both messages + form - make it fill and flex */
& > section > div {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 0;
min-height: 0;
}
/* messages: no top padding, fill available space */
& [role="listbox"] {
padding-top: 0;
flex: 1;
height: auto;
}
& [role="option"] {
padding-inline: var(--s-2);
}
/* form: stick to bottom, inline layout */
& form {
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1-5);
border-top: 1.5px solid var(--color-border);
margin-top: 0;
flex-shrink: 0;
}
& form input {
flex: 1;
}
/* bottomRow: hide connected text, inline send button */
& form > div {
display: contents;
}
& form > div > div {
display: none;
}
/* replace "Send" text with send-horizontal icon */
& form button {
font-size: 0;
width: 32px;
height: 32px;
padding: 0;
display: grid;
place-items: center;
flex-shrink: 0;
background-color: transparent;
mask-image: none;
}
}

View File

@@ -0,0 +1,555 @@
import clsx from "clsx";
import { ArrowLeft, MessageSquare, X } from "lucide-react";
import * as React from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useCurrentRouteChatCodes } from "~/features/chat/ChatProvider";
import type {
ChatContextValue,
RoomInfo,
} from "~/features/chat/chat-provider-types";
import { resolveDatePlaceholders } from "~/features/chat/chat-utils";
import { Chat } from "~/features/chat/components/Chat";
import { useChatContext } from "~/features/chat/useChatContext";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import sideNavStyles from "../SideNav.module.css";
import styles from "./ChatSidebar.module.css";
export function ChatSidebar({ onClose }: { onClose?: () => void }) {
const chatContext = useChatContext();
if (!chatContext) return null;
if (chatContext.activeRooms.length > 0) {
return <ChatView onClose={onClose} />;
}
if (chatContext.isLoading) {
return <LoadingState onClose={onClose} />;
}
return <RoomList onClose={onClose} />;
}
function SidebarHeader({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
return (
<div className={styles.sidebarHeader}>
<div className={sideNavStyles.iconContainer}>
<MessageSquare size={18} />
</div>
<h2>{t("common:chat.sidebar.title")}</h2>
{onClose ? (
<Button className={styles.closeButton} onPress={onClose}>
<X size={18} />
</Button>
) : null}
</div>
);
}
function LoadingState({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
return (
<div className={styles.sidebar}>
<SidebarHeader onClose={onClose} />
<div className={styles.roomList}>
<div className={styles.emptyState}>{t("common:chat.connecting")}</div>
</div>
</div>
);
}
function RoomList({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
const chatContext = useChatContext()!;
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const { formatter: timestampFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const routeChatCodes = useCurrentRouteChatCodes();
const visibleRooms = chatContext.rooms
.filter(
(room) =>
room.expiresAt > Date.now() || routeChatCodes.includes(room.chatCode),
)
.sort((a, b) => {
if (a.isObsolete !== b.isObsolete) return a.isObsolete ? 1 : -1;
const aRecency = a.lastMessageTimestamp || a.createdAt;
const bRecency = b.lastMessageTimestamp || b.createdAt;
return bRecency - aRecency;
});
// Rooms the active route groups together collapse into a single combined
// list entry that opens the stacked/tabbed view.
const combinedRooms =
routeChatCodes.length > 1
? routeChatCodes
.map((code) => visibleRooms.find((r) => r.chatCode === code))
.filter((r): r is RoomInfo => Boolean(r))
: [];
const isCombined = combinedRooms.length > 1;
const combinedChatCodes = new Set(combinedRooms.map((r) => r.chatCode));
const standaloneRooms = isCombined
? visibleRooms.filter((room) => !combinedChatCodes.has(room.chatCode))
: visibleRooms;
const openRooms = (chatCodes: string[]) => {
for (const chatCode of chatCodes) {
chatContext.requestHistory(chatCode);
chatContext.markAsRead(chatCode);
}
chatContext.setActiveRooms(chatCodes);
};
return (
<div className={styles.sidebar}>
<SidebarHeader onClose={onClose} />
<div className={styles.roomList}>
{!isCombined && standaloneRooms.length === 0 ? (
<div className={styles.emptyState}>
{t("common:chat.sidebar.noActiveChats")}
</div>
) : (
<>
{isCombined ? (
<CombinedRoomListItem
rooms={combinedRooms}
onPress={() =>
openRooms(combinedRooms.map((room) => room.chatCode))
}
/>
) : null}
{standaloneRooms.map((room) => {
const unread = chatContext.unreadCounts[room.chatCode] ?? 0;
return (
<Button
key={room.chatCode}
className={clsx(
sideNavStyles.listButton,
styles.roomItem,
room.isObsolete ? "opaque" : null,
)}
onPress={() => openRooms([room.chatCode])}
>
{room.imageUrl ? (
<img
src={room.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
<div className={sideNavStyles.listLinkContent}>
<span
className={clsx(
sideNavStyles.listLinkTitle,
styles.roomName,
room.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(
room.header,
(d) => headerFormatter.format(d) ?? "",
)}
</span>
<span className={sideNavStyles.listLinkSubtitle}>
{room.subtitle}
</span>
</div>
{unread > 0 && !room.isObsolete ? (
<span className={styles.unreadBadge}>{unread}</span>
) : room.lastMessageTimestamp > 0 ? (
<span className={styles.roomTimestamp}>
{timestampFormatter.format(
new Date(room.lastMessageTimestamp),
)}
</span>
) : null}
</Button>
);
})}
</>
)}
</div>
</div>
);
}
function CombinedRoomListItem({
rooms,
onPress,
}: {
rooms: RoomInfo[];
onPress: () => void;
}) {
const chatContext = useChatContext()!;
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const primary = rooms[0];
const unread = rooms.reduce(
(sum, room) => sum + (chatContext.unreadCounts[room.chatCode] ?? 0),
0,
);
return (
<Button
className={clsx(sideNavStyles.listButton, styles.roomItem)}
onPress={onPress}
>
{primary.imageUrl ? (
<img
src={primary.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
<div className={sideNavStyles.listLinkContent}>
<span className={clsx(sideNavStyles.listLinkTitle, styles.roomName)}>
{resolveDatePlaceholders(
primary.header,
(d) => headerFormatter.format(d) ?? "",
)}
</span>
<span className={sideNavStyles.listLinkSubtitle}>
{rooms.map((room) => roomShortLabel(room.header)).join(" · ")}
</span>
</div>
{unread > 0 ? <span className={styles.unreadBadge}>{unread}</span> : null}
</Button>
);
}
function ChatView({ onClose }: { onClose?: () => void }) {
const chatContext = useChatContext()!;
const activeRooms = chatContext.activeRooms
.map((code) => chatContext.rooms.find((r) => r.chatCode === code))
.filter((r): r is RoomInfo => Boolean(r));
if (activeRooms.length > 1) {
return <CombinedChatView rooms={activeRooms} onClose={onClose} />;
}
return <SingleChatView onClose={onClose} />;
}
function SingleChatView({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
const chatContext = useChatContext()!;
const activeRoom = chatContext.activeRooms[0];
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const routeChatCodes = useCurrentRouteChatCodes();
// Mirror the room list's badge visibility (RoomList): only rooms that are
// visible (non-expired or in route) and not obsolete contribute, so the
// back-arrow total can't outrun what the list can actually show.
const otherRoomsUnreadCount = chatContext.rooms
.filter(
(room) =>
room.chatCode !== activeRoom &&
!room.isObsolete &&
(room.expiresAt > Date.now() || routeChatCodes.includes(room.chatCode)),
)
.reduce(
(sum, room) => sum + (chatContext.unreadCounts[room.chatCode] ?? 0),
0,
);
const room = chatContext.rooms.find((r) => r.chatCode === activeRoom);
const roomExpired = Boolean(room?.expiresAt && room.expiresAt < Date.now());
const messages = chatContext.messagesForRoom(activeRoom);
const usersWithLabels = roomUsersWithLabels(chatContext, room);
const chatAdapter = {
messages,
send: (contents: string) => {
chatContext.send(activeRoom, contents);
},
currentRoom: activeRoom,
setCurrentRoom: () => {},
readyState: chatContext.readyState,
unseenMessages: new Map<string, number>(),
};
const handleBack = () => {
chatContext.setActiveRooms([]);
};
const headerContent = (
<>
{room?.imageUrl ? (
<img
src={room.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
<div className={styles.chatHeaderInfo}>
<span
className={clsx(
styles.chatHeaderTitle,
room?.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(
room?.header ?? t("common:chat.sidebar.title"),
(d) => headerFormatter.format(d) ?? "",
)}
</span>
{room?.subtitle ? (
<span className={styles.chatHeaderSubtitle}>{room.subtitle}</span>
) : null}
</div>
</>
);
return (
<div className={styles.sidebar}>
<div className={styles.chatHeader}>
<Button className={styles.backButton} onPress={handleBack}>
<ArrowLeft size={18} />
{otherRoomsUnreadCount > 0 ? (
<span className={styles.backButtonBadge}>
{otherRoomsUnreadCount}
</span>
) : null}
</Button>
{room?.url ? (
<Link to={room.url} className={styles.chatHeaderLink}>
{headerContent}
</Link>
) : (
<div className={styles.chatHeaderLink}>{headerContent}</div>
)}
{onClose ? (
<Button className={styles.closeButton} onPress={onClose}>
<X size={18} />
</Button>
) : null}
</div>
<div className={styles.chatContainer}>
<Chat
users={usersWithLabels}
rooms={[
{
label: room?.header ?? "Chat",
code: activeRoom,
},
]}
chat={chatAdapter}
disabled={roomExpired}
/>
</div>
</div>
);
}
function CombinedChatView({
rooms,
onClose,
}: {
rooms: RoomInfo[];
onClose?: () => void;
}) {
const chatContext = useChatContext()!;
const { formatter: headerFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const handleBack = () => {
chatContext.setActiveRooms([]);
};
const primary = rooms[0];
const headerContent = (
<>
{primary.imageUrl ? (
<img
src={primary.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
<div className={styles.chatHeaderInfo}>
<span className={styles.chatHeaderTitle}>
{resolveDatePlaceholders(
primary.header,
(d) => headerFormatter.format(d) ?? "",
)}
</span>
{primary.subtitle ? (
<span className={styles.chatHeaderSubtitle}>{primary.subtitle}</span>
) : null}
</div>
</>
);
return (
<div className={styles.sidebar}>
<div className={styles.chatHeader}>
<Button className={styles.backButton} onPress={handleBack}>
<ArrowLeft size={18} />
</Button>
{primary.url ? (
<Link to={primary.url} className={styles.chatHeaderLink}>
{headerContent}
</Link>
) : (
<div className={styles.chatHeaderLink}>{headerContent}</div>
)}
{onClose ? (
<Button className={styles.closeButton} onPress={onClose}>
<X size={18} />
</Button>
) : null}
</div>
<SplitPanels rooms={rooms} />
</div>
);
}
function SplitPanels({ rooms }: { rooms: RoomInfo[] }) {
const chatContext = useChatContext()!;
const isMobile = useLayoutSize() === "mobile";
// Both panels are on screen at once, so keep them all marked read while open,
// re-running whenever any room's message count changes.
const countsKey = rooms
.map((r) => `${r.chatCode}:${r.totalMessageCount}`)
.join(",");
React.useEffect(() => {
for (const entry of countsKey.split(",")) {
chatContext.markAsRead(entry.split(":")[0]);
}
}, [countsKey, chatContext.markAsRead]);
const [primary, ...rest] = rooms;
// Primary (match) sits on top, flush below the main header which already names
// it, so its sub-header is hidden. Desktop splits evenly; mobile gives the
// match chat the larger 3/5 share (group chat 2/5).
const panels = [
{ room: primary, grow: isMobile ? 3 : 1, showHeader: false },
...rest.map((room) => ({
room,
grow: isMobile ? 2 : 1,
showHeader: true,
})),
];
return (
<div className={styles.splitView}>
{panels.map(({ room, grow, showHeader }) => (
<SplitPanel
key={room.chatCode}
room={room}
grow={grow}
showHeader={showHeader}
/>
))}
</div>
);
}
function SplitPanel({
room,
grow,
showHeader,
}: {
room: RoomInfo;
grow: number;
showHeader: boolean;
}) {
return (
<div
className={styles.splitPanel}
style={{ "--split-grow": grow } as React.CSSProperties}
>
{showHeader ? (
<div className={styles.splitPanelHeader}>
{roomShortLabel(room.header)}
</div>
) : null}
<div className={styles.chatContainer}>
<RoomChat room={room} />
</div>
</div>
);
}
function RoomChat({ room }: { room: RoomInfo }) {
const chatContext = useChatContext()!;
const roomExpired = Boolean(room.expiresAt && room.expiresAt < Date.now());
const usersWithLabels = roomUsersWithLabels(chatContext, room);
const chatAdapter = {
messages: chatContext.messagesForRoom(room.chatCode),
send: (contents: string) => {
chatContext.send(room.chatCode, contents);
},
currentRoom: room.chatCode,
setCurrentRoom: () => {},
readyState: chatContext.readyState,
unseenMessages: new Map<string, number>(),
};
return (
<Chat
users={usersWithLabels}
rooms={[{ label: room.header, code: room.chatCode }]}
chat={chatAdapter}
disabled={roomExpired}
/>
);
}
function roomUsersWithLabels(
chatContext: ChatContextValue,
room: RoomInfo | undefined,
) {
const participantIds = new Set(room?.participantUserIds ?? []);
const usersWithLabels = { ...chatContext.chatUsers };
for (const [userIdStr, label] of Object.entries(chatContext.chatLabels)) {
const userId = Number(userIdStr);
if (participantIds.has(userId)) continue;
const existing = usersWithLabels[userId];
if (existing) {
usersWithLabels[userId] = { ...existing, title: label };
}
}
return usersWithLabels;
}
/** Concise label for tabs/split headers, e.g. "Match #123" -> "Match". */
function roomShortLabel(header: string): string {
const trimmed = header.trim();
if (!trimmed) return "Chat";
return trimmed.split(/[\s(#]/)[0] || trimmed;
}

View File

@@ -0,0 +1,133 @@
.footer {
display: flex;
flex-direction: column;
padding: var(--s-3);
background-color: var(--color-bg-high);
gap: var(--s-6);
margin-block-start: auto;
padding-block-end: var(--s-32);
}
.linkList {
display: flex;
justify-content: space-evenly;
font-size: var(--font-2xs);
& > * {
flex: 1;
text-align: center;
}
}
.socials {
display: flex;
justify-content: center;
gap: var(--s-2);
}
.socialIcon {
height: 2.25rem;
transition: transform 0.25s ease-in-out;
}
.socialLink {
display: flex;
max-width: 10rem;
height: 12rem;
flex: 1 1 0;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: var(--s-4);
border-radius: var(--radius-box);
background-color: var(--color-bg-higher);
cursor: pointer;
font-size: var(--font-lg);
&:hover > .socialIcon {
transform: translateY(-0.3rem);
}
}
.socialHeader {
text-align: center;
& > p {
font-size: var(--font-2xs);
}
}
.patronTitle {
display: flex;
align-items: flex-end;
justify-content: center;
font-size: var(--font-sm);
font-weight: var(--weight-semi);
gap: var(--s-2);
}
.patronList {
display: flex;
max-width: 75vw;
flex-wrap: wrap;
justify-content: center;
padding: 0;
margin: 0 auto;
font-size: var(--font-xs);
gap: var(--s-1);
list-style: none;
margin-block-start: var(--s-2);
}
.patron {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.copyrightNote {
display: flex;
flex-direction: column;
color: var(--color-text-high);
font-size: var(--font-2xs);
text-align: center;
}
.sourceLink {
margin-inline: auto;
font-size: var(--font-2xs);
color: var(--color-text-high);
font-style: italic;
}
@media screen and (max-width: 640px) {
.socials {
flex-direction: column;
}
.socialLink {
max-width: initial;
flex-direction: row;
&:hover > .socialIcon {
transform: translateX(-0.3rem);
}
}
.socialHeader {
display: flex;
align-items: center;
gap: var(--s-2);
text-align: initial;
& > p {
margin-block-start: var(--s-1);
}
}
.socialIcon {
height: 1.75rem;
}
}

View File

@@ -0,0 +1,133 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { usePatrons } from "~/hooks/swr";
import { GIT_COMMIT } from "~/utils/git-commit";
import {
API_PAGE,
CONTRIBUTIONS_PAGE,
FAQ_PAGE,
NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL,
SENDOU_INK_DISCORD_URL,
SENDOU_INK_GITHUB_URL,
SENDOU_LOVE_EMOJI_PATH,
SUPPORT_PAGE,
userPage,
WELCOME_PAGE,
} from "~/utils/urls";
import { Image } from "../Image";
import { DiscordIcon } from "../icons/Discord";
import { GitHubIcon } from "../icons/GitHub";
import { PatreonIcon } from "../icons/Patreon";
import styles from "./Footer.module.css";
export function Footer() {
const { t } = useTranslation();
const user = useUser();
const showPrivacySettings =
Config.fuseEnabled && !user?.roles.includes("MINOR_SUPPORT");
const currentYear = new Date().getFullYear();
return (
<footer className={styles.footer}>
<div className={styles.linkList}>
<Link to={CONTRIBUTIONS_PAGE}>{t("pages.contributors")}</Link>
<Link to={FAQ_PAGE}>{t("pages.faq")}</Link>
<Link to={WELCOME_PAGE}>{t("pages.welcome")}</Link>
<Link to={API_PAGE}>{t("pages.api")}</Link>
{showPrivacySettings ? <div data-fuse-privacy-tool /> : null}
</div>
<div className={styles.socials}>
<a
className={styles.socialLink}
href={SENDOU_INK_GITHUB_URL}
target="_blank"
rel="noreferrer"
>
<div className={styles.socialHeader}>
GitHub<p>{t("footer.github.subtitle")}</p>
</div>
<GitHubIcon className={styles.socialIcon} />
</a>
<a
className={styles.socialLink}
href={SENDOU_INK_DISCORD_URL}
target="_blank"
rel="noreferrer"
>
<div className={styles.socialHeader}>
Discord<p>{t("footer.discord.subtitle")}</p>
</div>{" "}
<DiscordIcon className={styles.socialIcon} />
</a>
<Link className={styles.socialLink} to={SUPPORT_PAGE}>
<div className={styles.socialHeader}>
Patreon<p>{t("footer.patreon.subtitle")}</p>
</div>{" "}
<PatreonIcon className={styles.socialIcon} />
</Link>
</div>
<PatronsList />
<div className={styles.copyrightNote}>
<p>
sendou.ink © Copyright of Sendou and contributors 2019-{currentYear}.
Original content & source code is licensed under the AGPL-3.0 license.
</p>
<p>
Splatoon is trademark & © of Nintendo 2014-{currentYear}. sendou.ink
is not affiliated with Nintendo.
</p>
<p>
All tournaments hosted on sendou.ink are unofficial and are not
sponsored by or affiliated with Nintendo. Terms for participating in
and viewing Community Tournaments using Nintendo Games can be found
here:{" "}
<a
href={NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL}
target="_blank"
rel="noreferrer"
>
{NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL}
</a>
</p>
</div>
{GIT_COMMIT ? (
<a
className={styles.sourceLink}
href={`${SENDOU_INK_GITHUB_URL}/commits/${GIT_COMMIT}/`}
target="_blank"
rel="noreferrer"
>
{t("footer.version")} {GIT_COMMIT.slice(0, 10)}
</a>
) : null}
</footer>
);
}
function PatronsList() {
const { t } = useTranslation();
const { patrons } = usePatrons();
return (
<div>
<h4 className={styles.patronTitle}>
{t("footer.thanks")}
<Image alt="" path={SENDOU_LOVE_EMOJI_PATH} width={24} height={24} />
</h4>
<ul className={styles.patronList}>
{patrons?.map((patron) => (
<li key={patron.id}>
<Link to={userPage(patron)} className={styles.patron}>
{patron.username}
</Link>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,302 @@
.searchButton {
display: flex;
align-items: center;
gap: var(--s-2);
padding: 0 var(--s-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-field);
background-color: var(--color-bg);
color: var(--color-text-high);
font-size: var(--font-xs);
cursor: pointer;
height: var(--field-size-sm);
transition: background-color 0.15s;
&:hover {
background-color: var(--color-bg-high);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.searchIcon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.searchPlaceholder {
flex-grow: 1;
text-align: left;
}
.searchKbd {
padding: 2px var(--s-1);
border-radius: var(--radius-field);
font-size: var(--font-2xs);
background-color: var(--color-bg-higher);
font-family: inherit;
}
.overlay {
position: fixed;
inset: 0;
z-index: 20;
background-color: rgba(0, 0, 0, 0.25);
display: flex;
align-items: flex-start;
justify-content: center;
backdrop-filter: blur(10px);
padding: var(--layout-main-padding);
padding-top: 15vh;
&[data-entering] {
animation: fade-in 200ms ease-out;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
width: 100%;
max-width: 36rem;
border: 1px solid var(--color-border);
background-color: var(--color-bg);
border-radius: var(--radius-box);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden;
&[data-entering] {
animation: zoom-in-95 200ms ease-out;
}
}
@keyframes zoom-in-95 {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.dialog {
outline: none;
}
.inputContainer {
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border);
&:focus-within {
border-color: var(--color-text-accent);
}
}
.inputPrefix {
font-size: var(--font-xs);
color: var(--color-text-high);
margin-left: var(--s-4);
background-color: var(--color-bg-high);
border-radius: var(--radius-selector);
height: var(--selector-size);
padding-inline: var(--s-1);
text-align: center;
width: var(--s-8);
line-height: 1.75;
}
.input.input {
padding: var(--s-4);
border: none;
border-radius: 0;
background: transparent;
font-size: var(--font-md);
color: var(--color-text);
height: auto;
padding-left: 0;
& input::placeholder {
color: var(--color-text-high);
}
&:focus-within {
outline: none;
}
}
.inputIcon {
color: var(--color-text-high);
flex-shrink: 0;
}
.listBox {
max-height: 325px;
overflow-y: auto;
padding: var(--s-2);
outline: none;
}
.listBoxItem {
display: block;
padding: var(--s-2) var(--s-3);
border-radius: var(--radius-field);
cursor: pointer;
font-size: var(--font-sm);
outline: none;
color: inherit;
text-decoration: none;
&[data-hovered] {
background-color: var(--color-bg-high);
}
&[data-focused] {
background-color: var(--color-bg-high);
}
&[data-selected] {
background-color: var(--color-accent-low);
}
}
.searchTypeContainer {
padding: var(--s-4);
border-bottom: 1px solid var(--color-border);
}
.searchTypeRadioGroup {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
}
.searchTypeRadioWrapper {
outline: none;
}
.searchTypeRadio {
display: flex;
align-items: center;
gap: var(--s-1);
padding: 0 var(--s-2);
height: var(--field-size-sm);
border-radius: var(--radius-field);
font-size: var(--font-xs);
cursor: pointer;
border: 1px solid var(--color-border);
background-color: var(--color-bg);
transition:
background-color 0.15s,
border-color 0.15s;
& picture,
& img {
flex-shrink: 0;
}
}
.searchTypeRadioSelected {
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
border-color: transparent;
}
.searchTypeRadioHovered {
background-color: var(--color-bg-high);
}
.searchTypeRadioFocusVisible {
outline: var(--focus-ring);
outline-offset: 1px;
}
.resultItem {
display: flex;
align-items: center;
gap: var(--s-2);
}
.resultTexts {
display: flex;
flex-direction: column;
min-width: 0;
}
.resultName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resultSecondary {
font-size: var(--font-xs);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resultLogo {
border-radius: var(--radius-field);
object-fit: cover;
}
.emptyState {
padding: var(--s-8);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-sm);
}
.weaponDestinationHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--color-border);
}
.backButton {
display: flex;
align-items: center;
justify-content: center;
height: var(--field-size-sm);
aspect-ratio: 1 / 1;
border: 1px solid var(--color-border);
border-radius: var(--radius-field);
background-color: var(--color-bg);
color: var(--color-text-high);
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: var(--color-bg-high);
}
}
.selectedWeaponName {
font-weight: var(--weight-semi);
}
/** needs to go away so we have enough space even with both side panels open */
@media screen and (max-width: 1100px) {
.searchKbd {
display: none;
}
}

View File

@@ -0,0 +1,498 @@
import clsx from "clsx";
import type { TFunction } from "i18next";
import { Search } from "lucide-react";
import * as React from "react";
import {
Button,
Dialog,
DialogTrigger,
ListBox,
ListBoxItem,
Modal,
ModalOverlay,
Radio,
RadioGroup,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Image } from "~/components/Image";
import { Input } from "~/components/Input";
import { LocaleTime } from "~/components/LocaleTime";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { searchSearchParams } from "~/features/search/search-search-params";
import { tournamentOrganizationPage } from "~/features/tournament-organization/tournament-organization-urls";
import { useDebounce } from "~/hooks/useDebounce";
import { useHydrated } from "~/hooks/useHydrated";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import {
navIconUrl,
teamPage,
userPage,
weaponCategoryUrl,
} from "~/utils/urls";
import styles from "./GlobalSearch.module.css";
import {
saveRecentWeapon,
searchTypePersisted,
useRecentWeapons,
} from "./global-search-persisted";
import {
globalSearchSearchParams,
GLOBAL_SEARCH_TYPES as SEARCH_TYPES,
type GlobalSearchType as SearchType,
} from "./global-search-search-params";
import {
filterWeaponResults,
type SelectedWeapon,
WeaponDestinationMenu,
WeaponResultsList,
weaponToSelectedWeapon,
} from "./WeaponSearch";
const SEARCH_TYPE_TO_PREFIX: Record<SearchType, string> = {
weapons: "w",
users: "u",
teams: "t",
organizations: "o",
tournaments: "to",
};
function searchTypeIconPath(type: SearchType): string {
if (type === "weapons") {
return weaponCategoryUrl("SHOOTERS");
}
const navIcons: Record<Exclude<SearchType, "weapons">, string> = {
users: "u",
teams: "t",
organizations: "medal",
tournaments: "calendar",
};
return navIconUrl(navIcons[type]);
}
export function GlobalSearch() {
const { t } = useTranslation(["common"]);
const [params, setParams] = useSearchParamsTyped(globalSearchSearchParams);
const isHydrated = useHydrated();
const isMac = isHydrated && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
const searchParamOpen = params.search === "open";
const [isOpen, setIsOpen] = React.useState(searchParamOpen);
const prevSearchParamOpen = React.useRef(searchParamOpen);
if (searchParamOpen && !prevSearchParamOpen.current) {
setIsOpen(true);
}
prevSearchParamOpen.current = searchParamOpen;
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const modifierKey = isMac ? e.metaKey : e.ctrlKey;
if (modifierKey && e.key === "k") {
e.preventDefault();
setIsOpen(true);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isMac]);
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (open) return;
if (
params.search === null &&
params.type === null &&
params.weapon === null
) {
return;
}
setParams({ search: null, type: null, weapon: null });
};
return (
<DialogTrigger isOpen={isOpen} onOpenChange={handleOpenChange}>
<Button className={styles.searchButton}>
<Search className={styles.searchIcon} />
<span className={styles.searchPlaceholder}>{t("common:search")}</span>
<kbd className={styles.searchKbd}>{isMac ? "Cmd+K" : "Ctrl+K"}</kbd>
</Button>
<ModalOverlay className={styles.overlay} isDismissable>
<Modal className={styles.modal}>
<Dialog className={styles.dialog} aria-label={t("common:search")}>
<GlobalSearchContent
onClose={() => setIsOpen(false)}
initialSearchType={params.type}
initialWeaponId={params.weapon}
/>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
);
}
function resolveInitialWeapon(
weaponId: MainWeaponId | null,
t: TFunction<["common", "weapons"]>,
): SelectedWeapon | null {
if (weaponId === null) return null;
const name = t(`weapons:MAIN_${weaponId}`);
if (!name || name === `MAIN_${weaponId}`) return null;
return weaponToSelectedWeapon(weaponId, t);
}
function GlobalSearchContent({
onClose,
initialSearchType,
initialWeaponId,
}: {
onClose: () => void;
initialSearchType: SearchType | null;
initialWeaponId: MainWeaponId | null;
}) {
const { t } = useTranslation(["common", "weapons"]);
const [query, setQuery] = React.useState("");
const [searchType, setSearchType] = React.useState<SearchType>(
() => initialSearchType ?? PersistedState.read(searchTypePersisted),
);
const [selectedWeapon, setSelectedWeapon] =
React.useState<SelectedWeapon | null>(
resolveInitialWeapon(initialWeaponId, t),
);
const inputRef = React.useRef<HTMLInputElement>(null);
const listBoxRef = React.useRef<HTMLDivElement>(null);
const modifierKeyRef = React.useRef(false);
const handleClickCapture = (e: React.MouseEvent) => {
modifierKeyRef.current = e.metaKey || e.ctrlKey;
};
const fetcher = useFetcher<SearchLoaderData>();
const recentWeaponIds = useRecentWeapons();
React.useEffect(() => {
if (!selectedWeapon) {
inputRef.current?.focus();
}
}, [selectedWeapon]);
useDebounce(
() => {
if (searchType === "weapons") return;
if (query.length < 3) return;
fetcher.load(
searchSearchParams.href("/search", {
q: query,
type: searchType as Exclude<SearchType, "weapons">,
limit: 10,
}),
);
},
300,
[query, searchType],
);
const hasQuery = query.length >= 3;
const fetchedQuery = fetcher.data?.query ?? null;
const fetchedType = fetcher.data?.type ?? null;
const isCurrentFetch =
hasQuery && fetchedQuery === query && fetchedType === searchType;
const results =
hasQuery && fetchedType === searchType ? (fetcher.data?.results ?? []) : [];
const weaponResults =
searchType === "weapons" && hasQuery ? filterWeaponResults(query, t) : [];
const recentWeapons: SelectedWeapon[] =
searchType === "weapons"
? recentWeaponIds.map((id) => weaponToSelectedWeapon(id, t))
: [];
const handleSelect = (key: React.Key) => {
if (searchType === "weapons") {
const weapon =
weaponResults.find((w) => `weapon-${w.id}` === key) ??
recentWeapons.find((w) => `weapon-${w.id}` === key);
if (weapon) {
setSelectedWeapon(weapon);
setQuery("");
}
return;
}
if (!modifierKeyRef.current) {
onClose();
}
};
const handleSearchTypeChange = (value: string) => {
setSearchType(value as SearchType);
PersistedState.write(searchTypePersisted, value as SearchType);
setSelectedWeapon(null);
};
const handleSearchTypeClick = () => {
inputRef.current?.focus();
};
const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const separatorMatch = value.match(/^([a-zA-Z]+)\.$/);
if (separatorMatch) {
const typedPrefix = separatorMatch[1];
const matchedType = SEARCH_TYPES.find(
(type) => SEARCH_TYPE_TO_PREFIX[type] === typedPrefix,
);
if (matchedType) {
setSearchType(matchedType);
PersistedState.write(searchTypePersisted, matchedType);
setSelectedWeapon(null);
setQuery("");
return;
}
}
setQuery(value);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const currentResults = searchType === "weapons" ? weaponResults : results;
if (e.key === "ArrowDown" && currentResults.length > 0) {
e.preventDefault();
listBoxRef.current?.focus();
}
};
const handleDestinationSelect = () => {
if (!selectedWeapon) return;
saveRecentWeapon(selectedWeapon.id);
if (!modifierKeyRef.current) {
onClose();
}
};
const handleBackToWeaponSearch = () => {
setSelectedWeapon(null);
};
if (searchType === "weapons" && selectedWeapon) {
return (
<div onClickCapture={handleClickCapture}>
<WeaponDestinationMenu
selectedWeapon={selectedWeapon}
onBack={handleBackToWeaponSearch}
onSelect={handleDestinationSelect}
listBoxRef={listBoxRef}
/>
</div>
);
}
return (
<div onClickCapture={handleClickCapture}>
<div className={styles.inputContainer}>
<p className={styles.inputPrefix}>
{`${SEARCH_TYPE_TO_PREFIX[searchType]}.`}
</p>
<Input
ref={inputRef}
className={styles.input}
placeholder={t("common:search.placeholder")}
value={query}
onChange={handleQueryChange}
onKeyDown={handleInputKeyDown}
icon={<Search className={styles.inputIcon} />}
/>
</div>
<div className={styles.searchTypeContainer}>
<RadioGroup
value={searchType}
onChange={handleSearchTypeChange}
orientation="horizontal"
aria-label="Search type"
className={styles.searchTypeRadioGroup}
>
{SEARCH_TYPES.map((type) => (
<Radio
key={type}
value={type}
className={styles.searchTypeRadioWrapper}
onClick={handleSearchTypeClick}
>
{({ isSelected, isHovered, isFocusVisible }) => (
<span
className={clsx(styles.searchTypeRadio, {
[styles.searchTypeRadioSelected]: isSelected,
[styles.searchTypeRadioHovered]: isHovered && !isSelected,
[styles.searchTypeRadioFocusVisible]: isFocusVisible,
})}
>
<Image path={searchTypeIconPath(type)} size={18} alt="" />
{t(`common:search.type.${type}`)}
</span>
)}
</Radio>
))}
</RadioGroup>
</div>
{searchType === "weapons" ? (
<WeaponResultsList
weaponResults={weaponResults}
recentWeapons={recentWeapons}
onSelect={handleSelect}
hasQuery={hasQuery}
listBoxRef={listBoxRef}
/>
) : (
<ListBox
ref={listBoxRef}
className={clsx(styles.listBox, "scrollbar")}
aria-label={t("common:search")}
onAction={handleSelect}
renderEmptyState={() => {
if (!hasQuery) {
return (
<div className={styles.emptyState}>
{t("common:search.hint")}
</div>
);
}
if (!isCurrentFetch) {
return (
<div className={styles.emptyState}>
{t("common:search.searching")}
</div>
);
}
return (
<div className={styles.emptyState}>
{t("common:search.noResults")}
</div>
);
}}
>
{results.map((result) => (
<ListBoxItem
key={getResultKey(result)}
id={getResultKey(result)}
href={getResultHref(result)}
className={styles.listBoxItem}
>
<ResultItem result={result} />
</ListBoxItem>
))}
</ListBox>
)}
</div>
);
}
type SearchResult = NonNullable<SearchLoaderData>["results"][number];
function getResultKey(result: SearchResult): string {
switch (result.type) {
case "user":
return `user-${result.id}`;
case "team":
return `team-${result.customUrl}`;
case "organization":
return `org-${result.id}`;
case "tournament":
return `tournament-${result.id}`;
}
}
function getResultHref(result: SearchResult): string {
switch (result.type) {
case "user":
return userPage({
discordId: result.discordId,
customUrl: result.customUrl,
});
case "team":
return teamPage(result.customUrl);
case "organization":
return tournamentOrganizationPage({ organizationSlug: result.slug });
case "tournament":
return `/to/${result.id}`;
}
}
function ResultItem({ result }: { result: SearchResult }) {
switch (result.type) {
case "user":
return (
<div className={styles.resultItem}>
<Avatar
user={{
discordId: result.discordId,
discordAvatar: result.discordAvatar,
}}
size="xxs"
/>
<div className={styles.resultTexts}>
<span className={styles.resultName}>{result.name}</span>
{result.inGameName ? (
<span className={styles.resultSecondary}>
{result.inGameName}
</span>
) : null}
</div>
</div>
);
case "team":
return (
<div className={styles.resultItem}>
<Avatar
url={result.avatarUrl}
size="xxs"
identiconInput={result.name}
/>
<span className={styles.resultName}>{result.name}</span>
</div>
);
case "organization":
return (
<div className={styles.resultItem}>
<Avatar
url={result.avatarUrl}
size="xxs"
identiconInput={result.name}
/>
<span className={styles.resultName}>{result.name}</span>
</div>
);
case "tournament":
return (
<div className={styles.resultItem}>
{result.logoUrl ? (
<img
src={result.logoUrl}
alt=""
width={24}
height={24}
className={styles.resultLogo}
/>
) : null}
<div className={styles.resultTexts}>
<span className={styles.resultName}>{result.name}</span>
<LocaleTime
date={result.startsAt}
options={{ day: "numeric", month: "long", year: "numeric" }}
className={styles.resultSecondary}
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,13 @@
import { LOG_IN_URL } from "~/utils/urls";
export function LogInButtonContainer({
children,
}: {
children: React.ReactNode;
}) {
return (
<form action={LOG_IN_URL} method="post" className="stack items-center">
{children}
</form>
);
}

View File

@@ -0,0 +1,70 @@
.popoverContainer {
min-width: 300px;
padding: 0;
background-color: var(--color-bg);
& svg {
width: 16px;
}
}
.noNotificationsContainer {
min-height: 200px;
}
.header {
display: flex;
align-items: center;
font-size: var(--font-sm);
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
& svg {
min-width: 18px;
min-height: 18px;
max-width: 18px;
max-height: 18px;
}
}
.noNotifications {
display: grid;
place-items: center;
font-weight: var(--weight-semi);
color: var(--color-text-high);
margin-block-start: 65px;
}
.divider {
border-color: var(--color-border);
}
.viewAllLink {
display: flex;
align-items: center;
gap: 2px;
width: fit-content;
margin: var(--s-2) auto;
font-size: var(--font-2xs);
color: var(--color-text-high);
text-decoration: none;
padding: 0 var(--s-3);
height: var(--selector-size);
background-color: var(--color-bg-high);
border-radius: var(--radius-field);
& svg {
stroke-width: 3;
}
&:hover {
color: var(--color-text);
background-color: var(--color-bg-higher);
}
}
@media screen and (max-width: 599px) {
.viewAllLink {
margin-top: var(--s-4);
}
}

View File

@@ -0,0 +1,107 @@
import { Bell, ChevronRight } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
NotificationItem,
NotificationItemDivider,
NotificationsList,
} from "~/features/notifications/components/NotificationList";
import {
type NotificationsData,
useNotificationsData,
} from "~/features/notifications/NotificationsProvider";
import { NOTIFICATIONS } from "~/features/notifications/notifications-contants";
import { NOTIFICATIONS_URL } from "~/utils/urls";
import {
useMarkNotificationsAsSeen,
useShowUnseenDot,
useStickyUnseenIds,
} from "../../features/notifications/notifications-hooks";
import styles from "./NotificationPopover.module.css";
export type LoaderNotification = NonNullable<NotificationsData>[number];
export function useNotifications() {
const { notifications } = useNotificationsData();
const unseenIds = React.useMemo(
() =>
notifications
?.filter((notification) => !notification.seen)
.map((notification) => notification.id) ?? [],
[notifications],
);
const showUnseenDot = useShowUnseenDot(notifications);
return { notifications, unseenIds, showUnseenDot };
}
export function NotificationContent({
notifications,
unseenIds,
onClose,
}: {
notifications: LoaderNotification[];
unseenIds: number[];
onClose?: () => void;
}) {
const { t } = useTranslation(["common"]);
const stickyUnseenIds = useStickyUnseenIds(notifications);
useMarkNotificationsAsSeen(unseenIds);
return (
<>
<h2 className={styles.header}>
<Bell /> {t("common:notifications.title")}
</h2>
<hr className={styles.divider} />
{notifications.length === 0 ? (
<div className={styles.noNotifications}>
{t("common:notifications.empty")}
</div>
) : (
<NotificationsList>
{notifications.map((notification, i) => (
<React.Fragment key={notification.id}>
<NotificationItem
key={notification.id}
notification={{
...notification,
seen: Number(!stickyUnseenIds.has(notification.id)),
}}
onClose={onClose}
/>
{i !== notifications.length - 1 && <NotificationItemDivider />}
</React.Fragment>
))}
</NotificationsList>
)}
{notifications.length === NOTIFICATIONS.PEEK_COUNT ? (
<NotificationsFooter onClose={onClose} />
) : null}
</>
);
}
function NotificationsFooter({ onClose }: { onClose?: () => void }) {
const { t } = useTranslation(["common"]);
return (
<div>
<hr className={styles.divider} />
<Link
to={NOTIFICATIONS_URL}
className={styles.viewAllLink}
data-testid="notifications-see-all-button"
onClick={onClose}
>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</div>
);
}

View File

@@ -0,0 +1,126 @@
.container {
display: none;
align-items: center;
@media screen and (min-width: 600px) {
display: flex;
}
}
.menuWrapper {
position: relative;
}
.menuButton {
display: flex;
align-items: center;
padding: 0 var(--s-2);
height: var(--field-size-sm);
border: none;
border-radius: var(--radius-field);
background: transparent;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
cursor: pointer;
white-space: nowrap;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.menuPopover {
background-color: var(--color-bg-high);
}
.menuContent {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-0-5);
}
.menuItem {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1-5) var(--s-2);
border-radius: var(--radius-field);
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-decoration: none;
transition:
background-color 0.15s,
color 0.15s;
&:hover,
&:focus-visible {
background-color: var(--color-bg-higher);
}
}
.menuItemForm {
display: contents;
}
.menuItemButton {
width: 100%;
border: none;
background: transparent;
font: inherit;
text-align: start;
cursor: pointer;
}
.menuItemIcon {
flex-shrink: 0;
}
.preview {
position: absolute;
top: calc(100% + var(--s-1));
left: 0;
z-index: 1;
display: none;
width: max-content;
grid-auto-flow: column;
grid-template-rows: repeat(2, auto);
gap: var(--s-1);
padding: var(--s-1) var(--s-1-5);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg-high);
.menuWrapper:hover & {
display: grid;
}
&::before {
content: "";
position: absolute;
top: calc(-1 * (var(--s-1) + var(--border-width)));
left: 0;
right: 0;
height: calc(var(--s-1) + var(--border-width));
}
}
.previewIcon {
display: flex;
flex-shrink: 0;
padding: var(--s-1);
border-radius: var(--radius-full);
transition: background-color 0.15s;
&:hover {
background-color: var(--color-bg-higher);
}
}
.previewIconButton {
border: none;
background: transparent;
cursor: pointer;
}

View File

@@ -0,0 +1,274 @@
import clsx from "clsx";
import { useState } from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Form, Link, useLocation } from "react-router";
import { Config } from "~/config";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { useUser } from "~/features/auth/core/user";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import {
impersonateUrl,
navIconUrl,
STOP_IMPERSONATING_URL,
} from "~/utils/urls";
import { SendouPopover } from "../elements/Popover";
import { Image } from "../Image";
import styles from "./TopNavMenus.module.css";
const DEV_IMPERSONATE_ITEMS = [
{ name: "Sendou", icon: "sendou_love", action: impersonateUrl(ADMIN_ID) },
{ name: "N-ZAP", icon: "u", action: impersonateUrl(NZAP_TEST_ID) },
{ name: "Logged out", icon: "log_in", action: STOP_IMPERSONATING_URL },
] as const;
const DEV_LINK_ITEMS = [
{ name: "Components", icon: "settings", url: "/components" },
] as const;
const NAV_CATEGORIES = [
{
name: "play",
items: [
{ name: "sendouq", url: "q" },
{ name: "scrims", url: "scrims" },
{ name: "lfg", url: "lfg" },
{ name: "calendar", url: "calendar" },
{ name: "leaderboards", url: "leaderboards" },
...(Config.showLutiNavItem
? [{ name: "luti" as const, url: "luti" as const }]
: []),
],
},
{
name: "tools",
items: [
{ name: "analyzer", url: "analyzer" },
{ name: "comp-analyzer", url: "comp-analyzer" },
{ name: "object-damage-calculator", url: "object-damage-calculator" },
{ name: "plans", url: "plans" },
{ name: "maps", url: "maps" },
{ name: "tier-list-maker", url: "tier-list-maker" },
{ name: "xsearch", url: "xsearch" },
{
name: "admin",
url: "admin",
icon: "settings" as const,
staffOnly: true as const,
},
],
},
{
name: "community",
items: [
{ name: "builds", url: "builds" },
{ name: "art", url: "art" },
{ name: "articles", url: "a" },
{ name: "vods", url: "vods" },
{ name: "trophies", url: "trophies" },
{ name: "links", url: "links" },
{ name: "plus", url: "plus/suggestions" },
],
},
] as const;
export function TopNavMenus() {
return (
<nav className={styles.container}>
{NAV_CATEGORIES.map((category) => (
<CategoryMenu key={category.name} category={category} />
))}
{process.env.NODE_ENV === "development" ? <DevMenu /> : null}
</nav>
);
}
function DevMenu() {
const [isOpen, setIsOpen] = useState(false);
const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false);
const location = useLocation();
const returnTo = `${location.pathname}${location.search}`;
return (
<div className={styles.menuWrapper}>
<SendouPopover
trigger={
<Button
className={styles.menuButton}
onHoverStart={() => setIsPreviewSuppressed(false)}
>
Dev
</Button>
}
popoverClassName={styles.menuPopover}
placement="bottom start"
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<div className={styles.menuContent}>
{DEV_IMPERSONATE_ITEMS.map((item) => (
<Form
key={item.name}
className={styles.menuItemForm}
method="post"
action={item.action}
reloadDocument
>
<input type="hidden" name="returnTo" value={returnTo} />
<button
type="submit"
className={clsx(styles.menuItem, styles.menuItemButton)}
>
<Image
path={navIconUrl(item.icon)}
alt=""
size={20}
className={styles.menuItemIcon}
/>
{item.name}
</button>
</Form>
))}
{DEV_LINK_ITEMS.map((item) => (
<Link
key={item.name}
to={item.url}
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
setIsPreviewSuppressed(true);
}}
>
<Image
path={navIconUrl(item.icon)}
alt=""
size={20}
className={styles.menuItemIcon}
/>
{item.name}
</Link>
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
<div className={styles.preview}>
{DEV_IMPERSONATE_ITEMS.map((item) => (
<Form
key={item.name}
className={styles.menuItemForm}
method="post"
action={item.action}
reloadDocument
>
<input type="hidden" name="returnTo" value={returnTo} />
<button
type="submit"
className={clsx(styles.previewIcon, styles.previewIconButton)}
title={item.name}
aria-label={item.name}
tabIndex={-1}
>
<Image path={navIconUrl(item.icon)} alt="" size={20} />
</button>
</Form>
))}
{DEV_LINK_ITEMS.map((item) => (
<Link
key={item.name}
to={item.url}
className={styles.previewIcon}
title={item.name}
aria-label={item.name}
tabIndex={-1}
onClick={() => setIsPreviewSuppressed(true)}
>
<Image path={navIconUrl(item.icon)} alt="" size={20} />
</Link>
))}
</div>
) : null}
</div>
);
}
function CategoryMenu({
category,
}: {
category: (typeof NAV_CATEGORIES)[number];
}) {
const { t } = useTranslation(["common", "front"]);
const [isOpen, setIsOpen] = useState(false);
const [isPreviewSuppressed, setIsPreviewSuppressed] = useState(false);
const user = useUser();
const isStaff = user?.roles.includes("STAFF") ?? false;
const showStaffOnly = isStaff || process.env.NODE_ENV === "development";
const visibleItems = category.items.filter((item) => {
if ("staffOnly" in item && !showStaffOnly) return false;
if (item.name === "trophies" && !canAccessTrophies(user)) return false;
return true;
});
return (
<div className={styles.menuWrapper}>
<SendouPopover
trigger={
<Button
className={styles.menuButton}
onHoverStart={() => setIsPreviewSuppressed(false)}
>
{t(`front:nav.${category.name}`)}
</Button>
}
popoverClassName={styles.menuPopover}
placement="bottom start"
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<div className={styles.menuContent}>
{visibleItems.map((item) => (
<Link
key={item.url}
to={`/${item.url}`}
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
setIsPreviewSuppressed(true);
}}
>
<Image
path={navIconUrl("icon" in item ? item.icon : item.name)}
alt=""
size={20}
className={styles.menuItemIcon}
/>
{t(`common:pages.${item.name}`)}
</Link>
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
<div className={styles.preview}>
{visibleItems.map((item) => (
<Link
key={item.url}
to={`/${item.url}`}
className={styles.previewIcon}
title={t(`common:pages.${item.name}`)}
aria-label={t(`common:pages.${item.name}`)}
tabIndex={-1}
onClick={() => setIsPreviewSuppressed(true)}
>
<Image
path={navIconUrl("icon" in item ? item.icon : item.name)}
alt=""
size={20}
/>
</Link>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,105 @@
.container {
display: flex;
gap: var(--s-4);
margin-left: auto;
}
.supportWrapper {
@media screen and (max-width: 599px) {
display: none;
}
}
.supportWrapperCompact {
display: none;
}
.addNewWrapperCompact {
display: none;
}
@media screen and (min-width: 600px) {
@container (max-width: 600px) {
.supportWrapper {
display: none;
}
.supportWrapperCompact {
display: block;
}
}
@container (max-width: 475px) {
.addNewWrapper {
display: none;
}
.addNewWrapperCompact {
display: block;
}
.searchAndAddContainer {
grid-template-columns: 2fr 1fr;
}
}
}
.searchAndAddContainer {
display: grid;
grid-template-columns: 2fr max-content;
gap: var(--s-2);
}
.searchWrapper > button,
.addNewWrapper > button {
width: 100%;
}
.chatButtonWrapperPersistent {
display: none;
position: relative;
@media screen and (min-width: 1000px) {
display: block;
}
& > button {
width: 100%;
}
}
.chatButtonWrapperModal {
display: none;
position: relative;
@media screen and (min-width: 600px) and (max-width: 999px) {
display: block;
}
& > button {
width: 100%;
}
}
.chatUnreadBadge {
position: absolute;
top: -6px;
right: -6px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
border-radius: 9px;
display: grid;
place-items: center;
pointer-events: none;
}
@media screen and (max-width: 1000px) {
.searchAndAddContainer {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,120 @@
import { Heart, LogIn, MessageSquare } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SUPPORT_PAGE } from "~/utils/urls";
import { LinkButton, SendouButton } from "../elements/Button";
import { AnythingAdder } from "./AnythingAdder";
import { GlobalSearch } from "./GlobalSearch";
import { LogInButtonContainer } from "./LogInButtonContainer";
import styles from "./TopRightButtons.module.css";
export function TopRightButtons({
showSupport,
showSearch,
isLoggedIn,
onChatToggle,
onChatModalToggle,
chatUnreadCount,
}: {
showSupport: boolean;
showSearch: boolean;
isLoggedIn: boolean;
onChatToggle?: () => void;
onChatModalToggle?: () => void;
chatUnreadCount?: number;
}) {
const { t } = useTranslation(["common", "front"]);
return (
<div className={styles.container}>
{showSupport ? (
<>
<div className={styles.supportWrapper}>
<LinkButton
to={SUPPORT_PAGE}
size="small"
icon={<Heart />}
variant="outlined"
>
{t("common:pages.support")}
</LinkButton>
</div>
<div className={styles.supportWrapperCompact}>
<LinkButton
to={SUPPORT_PAGE}
size="small"
icon={<Heart />}
variant="outlined"
shape="square"
/>
</div>
</>
) : null}
{isLoggedIn ? (
<>
<div className={styles.searchAndAddContainer}>
{showSearch ? (
<div className={styles.searchWrapper}>
<GlobalSearch />
</div>
) : null}
<div className={styles.addNewWrapper}>
<AnythingAdder />
</div>
<div className={styles.addNewWrapperCompact}>
<AnythingAdder compact />
</div>
</div>
{onChatToggle ? (
<div className={styles.chatButtonWrapperPersistent}>
<ChatButton
variant="outlined"
onPress={onChatToggle}
unreadCount={chatUnreadCount}
/>
</div>
) : null}
{onChatModalToggle ? (
<div className={styles.chatButtonWrapperModal}>
<ChatButton
variant="outlined"
onPress={onChatModalToggle}
unreadCount={chatUnreadCount}
/>
</div>
) : null}
</>
) : (
<LogInButtonContainer>
<SendouButton type="submit" size="small" icon={<LogIn />}>
{t("front:mobileNav.login")}
</SendouButton>
</LogInButtonContainer>
)}
</div>
);
}
function ChatButton({
variant,
onPress,
unreadCount,
}: {
variant: "outlined" | "primary";
onPress: () => void;
unreadCount?: number;
}) {
return (
<>
<SendouButton
shape="square"
size="small"
icon={<MessageSquare />}
variant={variant}
onPress={onPress}
/>
{unreadCount ? (
<span className={styles.chatUnreadBadge}>{unreadCount}</span>
) : null}
</>
);
}

View File

@@ -0,0 +1,8 @@
.userItem {
outline-offset: 1px;
border-radius: var(--radius-avatar);
&:focus-visible {
outline: var(--focus-ring);
}
}

View File

@@ -0,0 +1,309 @@
import { clsx } from "clsx";
import type { Namespace, TFunction } from "i18next";
import {
Calculator,
ChartColumnBig,
ChevronLeft,
Flame,
FlaskConical,
ImageIcon,
SlidersHorizontal,
Users,
Videotape,
} from "lucide-react";
import type * as React from "react";
import { ListBox, ListBoxItem } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { filterWeapon } from "~/modules/in-game-lists/utils";
import {
canonicalWeaponSplId,
mainWeaponIds,
weaponIdToBaseWeaponId,
} from "~/modules/in-game-lists/weapon-ids";
import {
ANALYZER_URL,
LFG_PAGE,
mainWeaponImageUrl,
mySlugify,
VODS_PAGE,
weaponBuildPage,
weaponBuildPopularPage,
weaponBuildStatsPage,
weaponParamsPage,
} from "~/utils/urls";
import styles from "./GlobalSearch.module.css";
const WEAPON_DESTINATIONS = [
"builds",
"popular",
"stats",
"analyzer",
"params",
"vods",
"art",
"lfg",
] as const;
export type WeaponDestination = (typeof WEAPON_DESTINATIONS)[number];
export interface SelectedWeapon {
id: MainWeaponId;
name: string;
englishName: string;
slug: string;
paramsSlug: string;
}
/**
* Builds the {@link SelectedWeapon} for a main weapon id: its localized name plus the English-derived
* url slugs (the build pages slug from the weapon's canonical id, the params page slug from its base
* id). The caller's `t` must have the `weapons` namespace available.
*/
export function weaponToSelectedWeapon<Ns extends Namespace>(
id: MainWeaponId,
t: TFunction<Ns>,
): SelectedWeapon {
return {
id,
name: t(`weapons:MAIN_${id}` as never),
englishName: t(`weapons:MAIN_${id}` as never, { lng: "en" }),
slug: mySlugify(
t(`weapons:MAIN_${canonicalWeaponSplId(id)}` as never, { lng: "en" }),
),
paramsSlug: mySlugify(
t(`weapons:MAIN_${weaponIdToBaseWeaponId(id)}` as never, { lng: "en" }),
),
};
}
export function filterWeaponResults(
query: string,
t: TFunction<["common", "weapons"]>,
): SelectedWeapon[] {
if (!query) return [];
const matches: SelectedWeapon[] = [];
for (const id of mainWeaponIds) {
const isMatch = filterWeapon({
weapon: { type: "MAIN", id },
weaponName: t(`weapons:MAIN_${id}`),
searchTerm: query,
});
if (isMatch) {
matches.push(weaponToSelectedWeapon(id, t));
}
if (matches.length >= 10) break;
}
return matches;
}
function getWeaponDestinationUrl(
key: WeaponDestination,
weapon: SelectedWeapon,
): string {
const destinations: Record<WeaponDestination, string> = {
builds: weaponBuildPage(weapon.slug),
popular: weaponBuildPopularPage(weapon.slug),
stats: weaponBuildStatsPage(weapon.slug),
analyzer: `${ANALYZER_URL}?weapon=${weapon.id}`,
params: weaponParamsPage(weapon.paramsSlug),
vods: `${VODS_PAGE}?weapon=${weapon.id}`,
art: `/art?tab=showcase&tag=${encodeURIComponent(weapon.englishName.toLowerCase())}`,
lfg: `${LFG_PAGE}?q=w.${weapon.id}`,
};
return destinations[key];
}
export function WeaponDestinationMenu({
selectedWeapon,
onBack,
onSelect,
listBoxRef,
}: {
selectedWeapon: SelectedWeapon;
onBack: () => void;
onSelect: (key: React.Key) => void;
listBoxRef: React.RefObject<HTMLDivElement | null>;
}) {
const { t } = useTranslation(["common"]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onBack();
}
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: keyboard navigation for Escape to go back
<div onKeyDown={handleKeyDown}>
<div className={styles.weaponDestinationHeader}>
<button
type="button"
className={styles.backButton}
onClick={onBack}
aria-label={t("common:actions.back")}
>
<ChevronLeft size={16} />
</button>
<Image path={mainWeaponImageUrl(selectedWeapon.id)} size={24} alt="" />
<span className={styles.selectedWeaponName}>{selectedWeapon.name}</span>
</div>
<ListBox
ref={listBoxRef}
className={styles.listBox}
aria-label={selectedWeapon.name}
onAction={onSelect}
autoFocus="first"
>
<ListBoxItem
id="builds"
href={getWeaponDestinationUrl("builds", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<FlaskConical size={20} />
<span className={styles.resultName}>
{t("common:pages.builds")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
id="popular"
href={getWeaponDestinationUrl("popular", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<Flame size={20} />
<span className={styles.resultName}>
{t("common:pages.popularBuilds")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
id="stats"
href={getWeaponDestinationUrl("stats", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<ChartColumnBig size={20} />
<span className={styles.resultName}>
{t("common:pages.abilityStats")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
id="analyzer"
href={getWeaponDestinationUrl("analyzer", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<Calculator size={20} />
<span className={styles.resultName}>
{t("common:pages.analyzer")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
id="params"
href={getWeaponDestinationUrl("params", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SlidersHorizontal size={20} />
<span className={styles.resultName}>
{t("common:pages.params")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
id="vods"
href={getWeaponDestinationUrl("vods", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<Videotape size={20} />
<span className={styles.resultName}>{t("common:pages.vods")}</span>
</div>
</ListBoxItem>
<ListBoxItem
id="art"
href={getWeaponDestinationUrl("art", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<ImageIcon size={20} />
<span className={styles.resultName}>{t("common:pages.art")}</span>
</div>
</ListBoxItem>
<ListBoxItem
id="lfg"
href={getWeaponDestinationUrl("lfg", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<Users size={20} />
<span className={styles.resultName}>{t("common:pages.lfg")}</span>
</div>
</ListBoxItem>
</ListBox>
</div>
);
}
export function WeaponResultsList({
weaponResults,
recentWeapons,
onSelect,
hasQuery,
listBoxRef,
}: {
weaponResults: SelectedWeapon[];
recentWeapons: SelectedWeapon[];
onSelect: (key: React.Key) => void;
hasQuery: boolean;
listBoxRef: React.RefObject<HTMLDivElement | null>;
}) {
const { t } = useTranslation(["common"]);
const displayedWeapons = hasQuery ? weaponResults : recentWeapons;
const showNoResults = hasQuery && weaponResults.length === 0;
const showHint = !hasQuery && recentWeapons.length === 0;
return (
<ListBox
ref={listBoxRef}
className={clsx(styles.listBox, "scrollbar")}
aria-label={t("common:search")}
selectionMode="single"
onAction={onSelect}
renderEmptyState={() =>
showNoResults ? (
<div className={styles.emptyState}>
{t("common:search.noResults")}
</div>
) : showHint ? (
<div className={styles.emptyState}>{t("common:search.hint")}</div>
) : null
}
>
{displayedWeapons.map((weapon) => (
<ListBoxItem
key={`weapon-${weapon.id}`}
id={`weapon-${weapon.id}`}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<Image path={mainWeaponImageUrl(weapon.id)} size={24} alt="" />
<span className={styles.resultName}>{weapon.name}</span>
</div>
</ListBoxItem>
))}
</ListBox>
);
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/persisted-state/persisted-state-test-utils";
import {
recentWeaponsPersisted,
searchTypePersisted,
} from "./global-search-persisted";
describe("searchTypePersisted", () => {
test("round-trips", () => {
assertRoundTrips(searchTypePersisted, [
"weapons",
"users",
"teams",
"organizations",
"tournaments",
]);
});
test("decodes legacy plain-string values", () => {
expect(searchTypePersisted.decode("users")).toBe("users");
});
test("malformed values decode to the default", () => {
assertDecodesToDefault(searchTypePersisted, ["USER", "[1]"]);
});
});
describe("recentWeaponsPersisted", () => {
test("round-trips", () => {
assertRoundTrips(recentWeaponsPersisted, [[], [0, 10, 8000]]);
});
test("malformed values decode to the default", () => {
assertDecodesToDefault(recentWeaponsPersisted, ["not json", "[99999]"]);
});
});

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import { usePersistedState } from "~/modules/persisted-state/hooks";
import * as PersistedState from "~/modules/persisted-state/persisted-state";
import { numericEnum } from "~/utils/zod";
import { GLOBAL_SEARCH_TYPES } from "./global-search-search-params";
const MAX_RECENT_WEAPONS = 5;
export const searchTypePersisted = PersistedState.define({
key: "global-search-search-type",
storage: "local",
schema: z.enum(GLOBAL_SEARCH_TYPES),
default: "weapons",
});
export const recentWeaponsPersisted = PersistedState.define({
key: "command-palette-recent-weapons",
storage: "local",
schema: z.array(numericEnum(mainWeaponIds)),
default: [],
});
export function useRecentWeapons(): MainWeaponId[] {
const [recentWeapons] = usePersistedState(recentWeaponsPersisted);
return recentWeapons;
}
export function saveRecentWeapon(weaponId: MainWeaponId) {
PersistedState.write(
recentWeaponsPersisted,
PersistedState.prependToRecentList(
PersistedState.read(recentWeaponsPersisted),
weaponId,
MAX_RECENT_WEAPONS,
),
);
}

View File

@@ -0,0 +1,25 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { globalSearchSearchParams } from "./global-search-search-params";
describe("globalSearchSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(globalSearchSearchParams, {
search: [null, "open"],
type: [null, "weapons", "users", "teams", "organizations", "tournaments"],
weapon: [null, 0, 10, 8000],
});
});
test("malformed values decode to defaults", () => {
assertDecodesToDefault(globalSearchSearchParams, "search", [["closed"]]);
assertDecodesToDefault(globalSearchSearchParams, "type", [["USER"]]);
assertDecodesToDefault(globalSearchSearchParams, "weapon", [
["99999"],
["abc"],
]);
});
});

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import { numericEnum } from "~/utils/zod";
export const GLOBAL_SEARCH_TYPES = [
"weapons",
"users",
"teams",
"organizations",
"tournaments",
] as const;
export type GlobalSearchType = (typeof GLOBAL_SEARCH_TYPES)[number];
export const globalSearchSearchParams = SearchParams.define({
search: SP.param(z.enum(["open"]).nullable(), { loader: false }),
type: SP.param(z.enum(GLOBAL_SEARCH_TYPES).nullable(), { loader: false }),
weapon: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: false }),
});

View File

@@ -0,0 +1,336 @@
.container {
flex: 1;
min-width: 0;
}
.header {
container-type: inline-size;
display: flex;
width: 100%;
height: var(--layout-nav-height);
align-items: center;
gap: var(--s-2);
border-bottom: 1.5px solid var(--color-border);
background-color: var(--color-bg-nav);
font-weight: var(--weight-extra);
padding-inline: var(--s-4);
position: sticky;
top: 0;
z-index: 10;
}
.siteTitleFlipper {
min-width: 0;
padding: 3px;
margin: -3px;
}
.siteTitle {
display: flex;
align-items: center;
gap: var(--s-2);
height: 100%;
min-width: 0;
padding: 3px;
margin: -3px;
}
.siteLogo {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 40px;
height: 36px;
background-color: var(--color-text-accent);
border-radius: var(--radius-field);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
text-decoration: none;
flex-shrink: 0;
transition: background-color 0.2s;
line-height: 1;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.siteLogoS {
font-size: 14px;
position: relative;
top: -4px;
}
.siteLogoInk {
font-size: 12px;
position: relative;
bottom: -4px;
}
.separator {
font-size: var(--font-xs);
color: var(--color-text-high);
font-weight: var(--weight-bold);
opacity: 0.5;
animation: fadeIn 200ms ease-out 150ms both;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 0.5;
}
}
.pageIconWrapper {
display: flex;
align-items: center;
flex-shrink: 0;
width: 28px;
height: 28px;
animation: fadeInFull 200ms ease-out 150ms both;
}
.pageIcon {
width: 28px;
height: 28px;
object-fit: cover;
}
@keyframes fadeInFull {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.breadcrumbLink {
display: flex;
align-items: center;
text-decoration: none;
border-radius: var(--radius-field);
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.pageName {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
animation: fadeInFull 200ms ease-out 150ms both;
}
@media screen and (display-mode: standalone) {
.header {
align-items: flex-end;
padding-top: calc(var(--s-2) + env(safe-area-inset-top));
padding-bottom: var(--s-2);
height: calc(var(--layout-nav-height) + env(safe-area-inset-top));
}
.sideNavModalOverlay,
.chatSidebarModalOverlay {
top: calc(var(--layout-nav-height) + env(safe-area-inset-top));
}
}
.sideNavEmpty {
font-size: var(--font-2xs);
color: var(--color-text-high);
padding: var(--s-1) var(--s-2);
margin: 0 auto;
font-style: italic;
}
.viewAllLink {
display: flex;
align-items: center;
gap: 2px;
& svg {
stroke-width: 3;
}
}
.friendRequestsBadge {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
border-radius: 9px;
display: grid;
place-items: center;
margin-inline-end: var(--s-1-5);
pointer-events: none;
}
.sideNavCollapseBadge {
position: absolute;
top: -5px;
inset-inline-end: -5px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 16px;
height: 16px;
padding: 0 var(--s-0-5);
border-radius: 8px;
display: grid;
place-items: center;
line-height: 1;
pointer-events: none;
&.sideNavCollapseBadgeLeft {
inset-inline-end: auto;
inset-inline-start: -5px;
}
}
.sideNavCollapseButtonContainer {
display: none;
position: relative;
--dot-top: 2px;
--dot-right: 2px;
&:has(.sideNavCollapseButton) {
@media screen and (min-width: 1000px) {
display: flex;
}
}
&:has(.sideNavModalTrigger) {
@media screen and (min-width: 600px) and (max-width: 999px) {
display: flex;
}
}
}
/* Doubled selector increases specificity to beat Button.module.css's display: flex */
.sideNavCollapseButton.sideNavCollapseButton,
.sideNavModalTrigger.sideNavModalTrigger {
display: none;
& svg {
color: var(--color-text);
min-width: 20px;
max-width: 20px;
}
&.sideNavCollapseButton {
@media screen and (min-width: 1000px) {
display: flex;
}
}
&.sideNavModalTrigger {
@media screen and (min-width: 600px) and (max-width: 999px) {
display: flex;
}
}
}
.sideNavModalOverlay {
position: fixed;
inset: 0;
top: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 20;
}
.sideNavModal {
height: 100%;
width: var(--layout-sidenav-width);
border-right: 1.5px solid var(--color-border);
}
.sideNavModalDialog {
outline: none;
height: 100%;
}
.sideNavInModal.sideNavInModal {
display: flex;
position: static;
height: 100%;
min-width: unset;
max-width: unset;
border-right: none;
}
.mobileLogo {
display: flex;
@media screen and (min-width: 600px) {
display: none;
}
}
.chatSidebar {
display: none;
background-color: var(--color-bg-nav);
min-width: var(--layout-sidenav-width);
max-width: var(--layout-sidenav-width);
border-left: 1.5px solid var(--color-border);
position: sticky;
top: 0;
right: 0;
height: 100dvh;
flex-direction: column;
overflow: hidden;
@media screen and (min-width: 1000px) {
display: flex;
}
}
@media screen and (max-width: 1474px) {
.sidebarFuseSpace {
padding-bottom: var(--layout-fuse-bottom-height);
}
}
.chatSidebarModalOverlay {
position: fixed;
inset: 0;
top: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 20;
}
.chatSidebarModal {
height: 100%;
width: var(--layout-sidenav-width);
margin-left: auto;
border-left: 1.5px solid var(--color-border);
background-color: var(--color-bg-nav);
}
.chatSidebarModalDialog {
outline: none;
height: 100%;
}
@media screen and (min-width: 1475px) {
:global(html[data-fuse="true"]) .chatSidebar {
z-index: 20;
}
}

View File

@@ -0,0 +1,746 @@
import clsx from "clsx";
import { isToday, isTomorrow } from "date-fns";
import {
Bell,
Calendar,
ChevronRight,
LogIn,
PanelLeft,
Settings,
Tv,
Users,
} from "lucide-react";
import * as React from "react";
import {
Button,
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components";
import { Flipped, Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Link, useFetcher, useLocation, useMatches } from "react-router";
import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { useLayoutData } from "~/features/layout/LayoutDataProvider";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import { useVisualViewportHeight } from "~/hooks/useVisualViewportHeight";
import { useSearchParam } from "~/modules/search-params/hooks";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import {
EVENTS_PAGE,
FRIENDS_PAGE,
PLANNER_URL,
SETTINGS_PAGE,
userPage,
} from "~/utils/urls";
import { Avatar, generateIdenticon } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
import { FuseZone } from "../fuse/Fuse";
import { Image } from "../Image";
import { MobileNav } from "../MobileNav";
import { NotificationDot } from "../NotificationDot";
import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
import sideNavStyles from "../SideNav.module.css";
import { StreamListItems } from "../StreamListItems";
import { ChatSidebar } from "./ChatSidebar";
import { Footer } from "./Footer";
import styles from "./index.module.css";
import { LogInButtonContainer } from "./LogInButtonContainer";
import { authErrorSearchParams } from "./layout-search-params";
import { NotificationContent, useNotifications } from "./NotificationPopover";
import notificationPopoverStyles from "./NotificationPopover.module.css";
import { TopNavMenus } from "./TopNavMenus";
import { TopRightButtons } from "./TopRightButtons";
const MAX_DESKTOP_FRIENDS = 4;
// lazy loaded so the rarely needed auth error dialog stays out of the eager
// bundle loaded on every page
const AuthErrorDialog = React.lazy(() =>
import("./AuthErrorDialog").then((module) => ({
default: module.AuthErrorDialog,
})),
);
/** Id of the loading-bar track rendered inside the header. NProgress mounts its
* bar into it; the track sits just below the header border, spans only the area
* between the sidebars, and clips the bar so it never extends over a sidebar.
* Living inside the header makes it follow the header on scroll and in
* standalone (PWA) mode where the header grows by the safe-area inset. */
export const NPROGRESS_ANCHOR_ID = "nprogress-anchor";
function useRelativeDayFormat() {
const { i18n } = useTranslation();
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const { formatter: dateTimeFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const formatRelativeDay = (daysFromToday: number) => {
const rtf = new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" });
const str = rtf.format(daysFromToday, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
};
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const timeStr = timeFormatter.format(date);
if (isToday(date)) {
return `${formatRelativeDay(0)}, ${timeStr}`;
}
if (isTomorrow(date)) {
return `${formatRelativeDay(1)}, ${timeStr}`;
}
return dateTimeFormatter.format(date);
};
return { formatRelativeDate };
}
function useBreadcrumbData() {
const { t } = useTranslation();
const matches = useMatches();
const breadcrumbs: Breadcrumb[] = [];
for (const match of [...matches].reverse()) {
const handle = match.handle as SendouRouteHandle | undefined;
const resolved = handle?.breadcrumb?.({ match, t });
if (resolved) {
const items = Array.isArray(resolved) ? resolved : [resolved];
breadcrumbs.push(...items);
}
}
return {
breadcrumbs,
currentPageText: breadcrumbs.at(-1)?.text,
};
}
function useSideNavCollapsed(initialCollapsed: boolean) {
const [collapsed, setCollapsed] = React.useState(initialCollapsed);
const fetcher = useFetcher();
const setCollapsedAndPersist = (value: boolean) => {
setCollapsed(value);
fetcher.submit(
{ collapsed: String(value) },
{ method: "POST", action: "/sidenav" },
);
};
return [collapsed, setCollapsedAndPersist] as const;
}
function useNavOffset(headerRef: React.RefObject<HTMLElement | null>) {
const [navOffset, setNavOffset] = React.useState(0);
const lastScrollY = React.useRef(0);
const MOBILE_BREAKPOINT = 600;
const NAV_HEIGHT_FALLBACK = 55;
const SCROLL_THRESHOLD_PX = 200;
const scrollAccumulator = React.useRef(0);
React.useEffect(() => {
const handleScroll = () => {
if (window.innerWidth >= MOBILE_BREAKPOINT) {
setNavOffset(0);
lastScrollY.current = window.scrollY;
scrollAccumulator.current = 0;
return;
}
const navHeight = headerRef.current?.offsetHeight ?? NAV_HEIGHT_FALLBACK;
const currentScrollY = window.scrollY;
const scrollDelta = currentScrollY - lastScrollY.current;
const directionChanged =
(scrollDelta > 0 && scrollAccumulator.current < 0) ||
(scrollDelta < 0 && scrollAccumulator.current > 0);
if (directionChanged) {
scrollAccumulator.current = 0;
}
scrollAccumulator.current += scrollDelta;
if (Math.abs(scrollAccumulator.current) >= SCROLL_THRESHOLD_PX) {
const overflow =
scrollAccumulator.current > 0
? scrollAccumulator.current - SCROLL_THRESHOLD_PX
: scrollAccumulator.current + SCROLL_THRESHOLD_PX;
setNavOffset((prevOffset) => {
const newOffset = prevOffset - overflow;
return Math.max(-navHeight, Math.min(0, newOffset));
});
scrollAccumulator.current =
scrollAccumulator.current > 0
? SCROLL_THRESHOLD_PX
: -SCROLL_THRESHOLD_PX;
}
lastScrollY.current = currentScrollY;
};
const handleResize = () => {
if (window.innerWidth >= MOBILE_BREAKPOINT) {
setNavOffset(0);
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};
}, [headerRef]);
return navOffset;
}
export function Layout({
children,
data,
}: {
children: React.ReactNode;
data?: RootLoaderData;
}) {
const chatContext = useChatContext();
const [sideNavCollapsed, setSideNavCollapsed] = useSideNavCollapsed(
data?.sidenavCollapsed ?? false,
);
const [sideNavModalOpen, setSideNavModalOpen] = React.useState(false);
const [chatSidebarModalOpen, setChatSidebarModalOpen] = React.useState(false);
const layoutSize = useLayoutSize();
useVisualViewportHeight();
const chatSidebarOpen = chatContext?.chatOpen ?? false;
const setChatSidebarOpen = chatContext?.setChatOpen ?? (() => {});
const setChatSidebarModalOpenAndSync = (open: boolean) => {
setChatSidebarModalOpen(open);
setChatSidebarOpen(open);
};
const { t } = useTranslation(["front", "common", "friends"]);
const { formatRelativeDate } = useRelativeDayFormat();
const isHydrated = useHydrated();
const location = useLocation();
const [authError] = useSearchParam(authErrorSearchParams, "authError");
const headerRef = React.useRef<HTMLElement>(null);
const navOffset = useNavOffset(headerRef);
// modals only exist in the tablet layout, close them when resizing out of
// it or navigating to another page (setChatOpen is left as is on purpose,
// it belongs to a parent component and thus cannot be set during render)
const prevLayoutSize = React.useRef(layoutSize);
const prevPathname = React.useRef(location.pathname);
const leftTabletLayout =
prevLayoutSize.current === "tablet" && layoutSize !== "tablet";
const pathnameChanged = prevPathname.current !== location.pathname;
prevLayoutSize.current = layoutSize;
prevPathname.current = location.pathname;
if (leftTabletLayout || pathnameChanged) {
if (sideNavModalOpen) {
setSideNavModalOpen(false);
}
if (chatSidebarModalOpen) {
setChatSidebarModalOpen(false);
}
}
const user = useUser();
const { showUnseenDot } = useNotifications();
const { sidebar: sidebarData } = useLayoutData();
const events = sidebarData?.events ?? [];
const friends = sidebarData?.friends ?? [];
const unseenFriendRequests = useUnseenFriendRequests(
sidebarData?.incomingFriendRequestIds ?? [],
);
const streams = sidebarData?.streams ?? [];
const isFrontPage = location.pathname === "/";
const showLeaderboard =
Config.fuseEnabled &&
!data?.user?.roles.includes("MINOR_SUPPORT") &&
!location.pathname.includes("plans");
const sideNavFooterContent = (
<SideNavFooter>
<SideNavUserPanel />
</SideNavFooter>
);
const sideNavChildren = (
<>
<SideNavHeader
icon={<Calendar />}
action={
user ? (
<Link to={EVENTS_PAGE} className={styles.viewAllLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
) : null
}
>
{t("front:sideNav.myCalendar")}
</SideNavHeader>
{events.length > 0 ? (
events.map((event) => (
<ListLink
key={`${event.type}-${event.id}`}
to={event.url}
imageUrl={event.logoUrl ?? undefined}
user={event.user ?? undefined}
subtitle={
isHydrated ? (
formatRelativeDate(event.startsAt)
) : (
<span className="invisible">Placeholder</span>
)
}
>
{event.scrimStatus === "booked"
? t("front:sideNav.scrimVs", { opponent: event.name })
: event.scrimStatus === "looking"
? t("front:sideNav.lookingForScrim")
: event.scrimStatus === "requestPending"
? t("front:sideNav.scrimRequestPending")
: event.name}
</ListLink>
))
) : (
<div className={styles.sideNavEmpty}>{t("front:sideNav.noEvents")}</div>
)}
<SideNavHeader
icon={<Users />}
action={
user ? (
<>
{unseenFriendRequests > 0 ? (
<span
className={styles.friendRequestsBadge}
role="status"
aria-label={t("friends:unseenRequests", {
count: unseenFriendRequests,
})}
>
{unseenFriendRequests}
</span>
) : null}
<Link to={FRIENDS_PAGE} className={styles.viewAllLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</>
) : null
}
>
{t("front:sideNav.friends")}
</SideNavHeader>
{friends.length > 0 ? (
friends
.slice(0, MAX_DESKTOP_FRIENDS)
.map((friend) => <FriendMenu key={friend.id} {...friend} />)
) : (
<div className={styles.sideNavEmpty}>
{user
? t("front:sideNav.friends.noFriends")
: t("front:sideNav.friends.notLoggedIn")}
</div>
)}
<SideNavHeader icon={<Tv />}>{t("front:sideNav.streams")}</SideNavHeader>
{streams.length === 0 ? (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.noStreams")}
</div>
) : null}
<StreamListItems
streams={streams}
isLoggedIn={Boolean(user)}
savedTournamentIds={sidebarData?.savedTournamentIds}
/>
</>
);
return (
<>
<SideNav
className={showLeaderboard ? styles.sidebarFuseSpace : undefined}
collapsed={sideNavCollapsed}
footer={sideNavFooterContent}
top={<SiteTitle />}
topCentered={isFrontPage}
>
{sideNavChildren}
</SideNav>
<MobileNav sidebarData={sidebarData} />
<div className={styles.container}>
<header
ref={headerRef}
className={styles.header}
style={{
transform: `translateY(${navOffset}px)`,
}}
>
<Link to="/" className={clsx(styles.siteLogo, styles.mobileLogo)}>
<SiteLogoContent />
</Link>
<DialogTrigger
isOpen={sideNavModalOpen}
onOpenChange={setSideNavModalOpen}
>
<SideNavCollapseButton
className={styles.sideNavModalTrigger}
showNotificationDot={!sideNavModalOpen && showUnseenDot}
badgeCount={!sideNavModalOpen ? unseenFriendRequests : 0}
testId="sidenav-modal-trigger"
/>
<ModalOverlay className={styles.sideNavModalOverlay} isDismissable>
<Modal className={styles.sideNavModal}>
<Dialog className={styles.sideNavModalDialog}>
<SideNav
className={styles.sideNavInModal}
footer={sideNavFooterContent}
top={<SiteTitle />}
topCentered={isFrontPage}
>
{sideNavChildren}
</SideNav>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
<ModalOverlay
className={styles.chatSidebarModalOverlay}
isDismissable
isOpen={chatSidebarModalOpen}
onOpenChange={setChatSidebarModalOpenAndSync}
>
<Modal className={styles.chatSidebarModal}>
<Dialog
className={styles.chatSidebarModalDialog}
aria-label={t("common:chat.sidebar.title")}
>
<ChatSidebar />
</Dialog>
</Modal>
</ModalOverlay>
<SideNavCollapseButton
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
className={styles.sideNavCollapseButton}
showNotificationDot={sideNavCollapsed && showUnseenDot}
badgeCount={sideNavCollapsed ? unseenFriendRequests : 0}
testId="sidenav-collapse-button"
/>
<TopNavMenus />
<TopRightButtons
showSupport={Boolean(
data && !data?.user?.roles.includes("MINOR_SUPPORT"),
)}
showSearch={Boolean(data?.user)}
isLoggedIn={Boolean(data?.user)}
onChatToggle={
data?.user && !chatSidebarOpen
? () => setChatSidebarOpen(true)
: undefined
}
onChatModalToggle={
data?.user
? () => setChatSidebarModalOpenAndSync(!chatSidebarModalOpen)
: undefined
}
chatUnreadCount={chatContext?.totalUnreadCount}
/>
<div id={NPROGRESS_ANCHOR_ID} aria-hidden />
</header>
{showLeaderboard ? (
<FuseZone
id="fuse-header"
fuseSlot="header"
className="top-leaderboard"
/>
) : null}
{children}
<Footer />
</div>
{chatSidebarOpen && layoutSize === "desktop" ? (
<div
className={clsx(
styles.chatSidebar,
showLeaderboard && styles.sidebarFuseSpace,
)}
>
<ChatSidebar onClose={() => setChatSidebarOpen(false)} />
</div>
) : null}
{typeof authError === "string" ? (
<React.Suspense>
<AuthErrorDialog />
</React.Suspense>
) : null}
</>
);
}
function SiteTitle() {
const location = useLocation();
const prefersReducedMotion = usePrefersReducedMotion();
const { breadcrumbs, currentPageText } = useBreadcrumbData();
const isFrontPage = location.pathname === "/";
const hasBreadcrumbs = breadcrumbs.length > 0;
return (
<Flipper
flipKey={isFrontPage ? "front" : "other"}
className={styles.siteTitleFlipper}
decisionData={{ pathname: location.pathname }}
>
<div className={styles.siteTitle}>
<Flipped
flipId="site-logo"
shouldFlip={(prev, current) =>
!prefersReducedMotion &&
prev?.pathname !== PLANNER_URL &&
current?.pathname !== PLANNER_URL
}
>
<Link to="/" className={styles.siteLogo}>
<SiteLogoContent />
</Link>
</Flipped>
{hasBreadcrumbs ? (
<>
{breadcrumbs.map((crumb) => {
const isCurrentPage = location.pathname === crumb.href;
return (
<React.Fragment key={crumb.href}>
<span className={styles.separator}>/</span>
{isCurrentPage ? (
<PageIcon crumb={crumb} />
) : (
<Link to={crumb.href} className={styles.breadcrumbLink}>
<PageIcon crumb={crumb} />
</Link>
)}
</React.Fragment>
);
})}
{currentPageText ? (
<span className={styles.pageName}>{currentPageText}</span>
) : null}
</>
) : null}
</div>
</Flipper>
);
}
function SiteLogoContent() {
return (
<>
<span className={styles.siteLogoS}>S</span>
<span className={styles.siteLogoInk}>ink</span>
</>
);
}
function SideNavCollapseButton({
onToggle,
className,
showNotificationDot,
badgeCount,
testId,
}: {
onToggle?: () => void;
className?: string;
showNotificationDot?: boolean;
badgeCount?: number;
testId?: string;
}) {
const { t } = useTranslation(["friends"]);
return (
<div className={styles.sideNavCollapseButtonContainer} data-testid={testId}>
<SendouButton
className={className}
variant="minimal"
size="small"
shape="square"
icon={<PanelLeft />}
onPress={onToggle}
/>
{showNotificationDot ? <NotificationDot /> : null}
{badgeCount ? (
<span
className={clsx(styles.sideNavCollapseBadge, {
[styles.sideNavCollapseBadgeLeft]: showNotificationDot,
})}
role="status"
aria-label={t("friends:unseenRequests", { count: badgeCount })}
>
{badgeCount}
</span>
) : null}
</div>
);
}
function PageIcon({ crumb }: { crumb: Breadcrumb }) {
const [isErrored, setIsErrored] = React.useState(false);
const isClient = useHydrated();
if (crumb.type !== "IMAGE") {
return null;
}
const lastPathSegment = crumb.imgPath.split("/").pop() ?? "";
const isExternal = lastPathSegment.includes(".");
const iconClass = clsx(styles.pageIcon, "rounded");
// an <img> can finish loading (and fail) before React hydrates and attaches onError, so that
// error is missed — re-check on mount and fall back manually so SSR'd icons still heal
const checkAlreadyErrored = (img: HTMLImageElement | null) => {
if (img?.complete && img.naturalWidth === 0) setIsErrored(true);
};
const identiconSrc =
isErrored && isClient && crumb.identiconInput
? generateIdenticon(crumb.identiconInput, 28, 7)
: null;
return (
<div className={styles.pageIconWrapper}>
{isExternal ? (
<img
ref={checkAlreadyErrored}
src={identiconSrc ?? crumb.imgPath}
alt=""
className={iconClass}
width={28}
height={28}
onError={() => setIsErrored(true)}
/>
) : (
<Image
path={crumb.imgPath}
alt=""
className={iconClass}
width={20}
height={20}
/>
)}
</div>
);
}
function SideNavUserPanel() {
const { t } = useTranslation();
const location = useLocation();
const user = useUser();
const { notifications, unseenIds, showUnseenDot } = useNotifications();
if (user) {
return (
<>
<Link to={userPage(user)} className={sideNavStyles.sideNavFooterUser}>
<Avatar user={user} size="xs" />
<span className={sideNavStyles.sideNavFooterUsername}>
{user.username}
</span>
</Link>
<div className={sideNavStyles.sideNavFooterActions}>
{notifications ? (
<div
className={sideNavStyles.sideNavFooterNotification}
key={location.pathname}
>
{showUnseenDot ? (
<NotificationDot
className={sideNavStyles.sideNavFooterUnseenDot}
testId="notifications-bell-dot"
/>
) : null}
<SendouPopover
trigger={
<Button
className={sideNavStyles.sideNavFooterButton}
data-testid="notifications-button"
>
<Bell />
</Button>
}
popoverClassName={clsx(
notificationPopoverStyles.popoverContainer,
{
[notificationPopoverStyles.noNotificationsContainer]:
notifications.length === 0,
},
)}
>
<NotificationContent
notifications={notifications}
unseenIds={unseenIds}
/>
</SendouPopover>
</div>
) : null}
<Link
to={SETTINGS_PAGE}
className={sideNavStyles.sideNavFooterButton}
>
<Settings />
</Link>
</div>
</>
);
}
return (
<>
<LogInButtonContainer>
<SendouButton type="submit" size="small" icon={<LogIn />}>
{t("header.login.discord")}
</SendouButton>
</LogInButtonContainer>
<div className={sideNavStyles.sideNavFooterActions}>
<Link to={SETTINGS_PAGE} className={sideNavStyles.sideNavFooterButton}>
<Settings />
</Link>
</div>
</>
);
}

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const authErrorSearchParams = SearchParams.define({
authError: SP.param(z.string().nullable(), { loader: false }),
});

View File

@@ -0,0 +1,111 @@
import { Config } from "~/config";
export const navItems = [
{
name: "settings",
url: "settings",
prefetch: true,
},
Config.showLutiNavItem
? {
name: "luti",
url: "luti",
prefetch: false,
}
: null,
{
name: "sendouq",
url: "q",
prefetch: false,
},
{
name: "analyzer",
url: "analyzer",
prefetch: true,
},
{
name: "comp-analyzer",
url: "comp-analyzer",
prefetch: true,
},
{
name: "builds",
url: "builds",
prefetch: true,
},
{
name: "object-damage-calculator",
url: "object-damage-calculator",
prefetch: true,
},
{
name: "leaderboards",
url: "leaderboards",
prefetch: false,
},
{
name: "scrims",
url: "scrims",
prefetch: false,
},
{
name: "lfg",
url: "lfg",
prefetch: false,
},
{
name: "plans",
url: "plans",
prefetch: false,
},
{
name: "trophies",
url: "trophies",
prefetch: false,
},
{
name: "calendar",
url: "calendar",
prefetch: false,
},
{
name: "plus",
url: "plus/suggestions",
prefetch: false,
},
{
name: "xsearch",
url: "xsearch",
prefetch: false,
},
{
name: "articles",
url: "a",
prefetch: false,
},
{
name: "vods",
url: "vods",
prefetch: false,
},
{
name: "art",
url: "art",
prefetch: false,
},
{
name: "tier-list-maker",
url: "tier-list-maker",
prefetch: false,
},
{
name: "links",
url: "links",
prefetch: true,
},
{
name: "maps",
url: "maps",
prefetch: false,
},
].filter((item) => item !== null);