mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
135
apps/web-react/app/components/layout/AnythingAdder.tsx
Normal file
135
apps/web-react/app/components/layout/AnythingAdder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/web-react/app/components/layout/AuthErrorDialog.tsx
Normal file
65
apps/web-react/app/components/layout/AuthErrorDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
290
apps/web-react/app/components/layout/ChatSidebar.module.css
Normal file
290
apps/web-react/app/components/layout/ChatSidebar.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
555
apps/web-react/app/components/layout/ChatSidebar.tsx
Normal file
555
apps/web-react/app/components/layout/ChatSidebar.tsx
Normal 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;
|
||||
}
|
||||
133
apps/web-react/app/components/layout/Footer.module.css
Normal file
133
apps/web-react/app/components/layout/Footer.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
133
apps/web-react/app/components/layout/Footer.tsx
Normal file
133
apps/web-react/app/components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
302
apps/web-react/app/components/layout/GlobalSearch.module.css
Normal file
302
apps/web-react/app/components/layout/GlobalSearch.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
498
apps/web-react/app/components/layout/GlobalSearch.tsx
Normal file
498
apps/web-react/app/components/layout/GlobalSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
107
apps/web-react/app/components/layout/NotificationPopover.tsx
Normal file
107
apps/web-react/app/components/layout/NotificationPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/web-react/app/components/layout/TopNavMenus.module.css
Normal file
126
apps/web-react/app/components/layout/TopNavMenus.module.css
Normal 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;
|
||||
}
|
||||
274
apps/web-react/app/components/layout/TopNavMenus.tsx
Normal file
274
apps/web-react/app/components/layout/TopNavMenus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/web-react/app/components/layout/TopRightButtons.module.css
Normal file
105
apps/web-react/app/components/layout/TopRightButtons.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
120
apps/web-react/app/components/layout/TopRightButtons.tsx
Normal file
120
apps/web-react/app/components/layout/TopRightButtons.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
apps/web-react/app/components/layout/UserItem.module.css
Normal file
8
apps/web-react/app/components/layout/UserItem.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.userItem {
|
||||
outline-offset: 1px;
|
||||
border-radius: var(--radius-avatar);
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
}
|
||||
}
|
||||
309
apps/web-react/app/components/layout/WeaponSearch.tsx
Normal file
309
apps/web-react/app/components/layout/WeaponSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
336
apps/web-react/app/components/layout/index.module.css
Normal file
336
apps/web-react/app/components/layout/index.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
746
apps/web-react/app/components/layout/index.tsx
Normal file
746
apps/web-react/app/components/layout/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
111
apps/web-react/app/components/layout/nav-items.ts
Normal file
111
apps/web-react/app/components/layout/nav-items.ts
Normal 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);
|
||||
Reference in New Issue
Block a user