Merge branch 'css-rework-sidenav' of https://github.com/sendou-ink/sendou.ink into css-rework-sidenav

This commit is contained in:
hfcRed 2026-03-08 13:38:30 +01:00
commit 26ceb883e2
68 changed files with 2633 additions and 2301 deletions

View File

@ -0,0 +1,8 @@
.dayHeader {
padding: var(--s-2) var(--s-2) var(--s-1);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-high);
text-transform: uppercase;
letter-spacing: 0.05em;
}

View File

@ -0,0 +1,101 @@
import { isToday, isTomorrow } from "date-fns";
import { useTranslation } from "react-i18next";
import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server";
import styles from "./EventsList.module.css";
import { ListLink } from "./SideNav";
export function EventsList({
events,
onClick,
}: {
events: SidebarEvent[];
onClick?: () => void;
}) {
const { t, i18n } = useTranslation(["front"]);
if (events.length === 0) {
return (
<div className="text-lighter text-sm p-2">
{t("front:sideNav.noEvents")}
</div>
);
}
const getDayKey = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toDateString();
};
const formatDayHeader = (date: Date) => {
if (isToday(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
});
const str = rtf.format(0, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
}
if (isTomorrow(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
});
const str = rtf.format(1, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
}
return date.toLocaleDateString(i18n.language, {
weekday: "long",
month: "short",
day: "numeric",
});
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
});
};
const groupedEvents = events.reduce<Record<string, typeof events>>(
(acc, event) => {
const key = getDayKey(event.startTime);
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(event);
return acc;
},
{},
);
const dayKeys = Object.keys(groupedEvents);
return (
<>
{dayKeys.map((dayKey) => {
const dayEvents = groupedEvents[dayKey];
const firstDate = new Date(dayEvents[0].startTime * 1000);
return (
<div key={dayKey}>
<div className={styles.dayHeader}>{formatDayHeader(firstDate)}</div>
{dayEvents.map((event) => (
<ListLink
key={`${event.type}-${event.id}`}
to={event.url}
imageUrl={event.logoUrl ?? undefined}
subtitle={formatTime(new Date(event.startTime * 1000))}
onClick={onClick}
>
{event.scrimStatus === "booked"
? t("front:sideNav.scrimVs", { opponent: event.name })
: event.scrimStatus === "looking"
? t("front:sideNav.lookingForScrim")
: event.name}
</ListLink>
))}
</div>
);
})}
</>
);
}

View File

@ -55,7 +55,7 @@ export function GearSelect<Clearable extends boolean | undefined = undefined>({
>
{({ key, items: gear, brandId, idx }) => (
<SendouSelectItemSection
className={idx === 0 ? "pt-0-5-forced" : undefined}
className={idx === 0 ? "pt-0-5" : undefined}
heading={t(`game-misc:BRAND_${brandId}` as any)}
headingImgPath={brandImageUrl(brandId)}
key={key}

View File

@ -32,7 +32,7 @@ export const Main = ({
classNameOverwrite
? clsx(classNameOverwrite, {
[styles.narrow]: halfWidth,
"pt-8-forced": showLeaderboard,
"pt-8": showLeaderboard,
})
: clsx(
styles.main,
@ -40,7 +40,7 @@ export const Main = ({
{
[styles.narrow]: halfWidth,
[styles.wide]: bigger,
"pt-8-forced": showLeaderboard,
"pt-8": showLeaderboard,
},
className,
)

View File

@ -13,7 +13,8 @@ export function Markdown({ children }: { children: string }) {
.replace(/style\s*=\s*("[^"]*"|'[^']*')/gi, (_match, value) => {
const sanitized = value.replace(CSS_URL_REGEX, "");
return `style=${sanitized}`;
});
})
.replace(/ +$/gm, "");
return (
<MarkdownToJsx

View File

@ -77,40 +77,6 @@
font-style: italic;
}
.notificationDot {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background-color: var(--color-text-accent);
border-radius: 100%;
outline: 2px solid var(--color-bg);
pointer-events: none;
}
.notificationDotPulse {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: var(--color-text-accent);
box-shadow: 0 0 0 var(--color-text-accent);
animation: pulse 2s infinite;
}
@keyframes pulse {
from {
box-shadow: 0 0 0 0 var(--color-text-accent);
}
70% {
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
}
to {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
}
}
.panelOverlay {
position: fixed;
inset: 0;
@ -333,24 +299,24 @@
}
.panelSectionLink {
display: block;
display: flex;
align-items: center;
gap: 2px;
width: fit-content;
margin: var(--s-4) auto;
font-size: var(--font-2xs);
color: var(--color-text-high);
text-decoration: none;
padding: var(--s-3) var(--s-2);
text-align: center;
padding: var(--s-1) var(--s-3);
background-color: var(--color-bg-higher);
border-radius: var(--radius-field);
& svg {
stroke-width: 3;
}
}
.panelSectionLink:hover {
color: var(--color-text);
text-decoration: underline;
}
.dayHeader {
padding: var(--s-2) var(--s-2) var(--s-1);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-high);
text-transform: uppercase;
letter-spacing: 0.05em;
background-color: var(--color-bg-high);
}

View File

@ -1,7 +1,7 @@
import clsx from "clsx";
import { isToday, isTomorrow } from "date-fns";
import {
Calendar,
ChevronRight,
LogIn,
Menu,
Settings,
@ -18,12 +18,14 @@ import { useUser } from "~/features/auth/core/user";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import type { RootLoaderData } from "~/root";
import {
EVENTS_PAGE,
FRIENDS_PAGE,
navIconUrl,
SETTINGS_PAGE,
userPage,
} from "~/utils/urls";
import { Avatar } from "./Avatar";
import { EventsList } from "./EventsList";
import { SendouButton } from "./elements/Button";
import { Image } from "./Image";
import { LogInButtonContainer } from "./layout/LogInButtonContainer";
@ -33,7 +35,7 @@ import {
} from "./layout/NotificationPopover";
import { navItems } from "./layout/nav-items";
import styles from "./MobileNav.module.css";
import { ListLink } from "./SideNav";
import { NotificationDot } from "./NotificationDot";
import { StreamListItems } from "./StreamListItems";
type SidebarData = RootLoaderData["sidebar"] | undefined;
@ -169,11 +171,7 @@ function MobileTab({
>
<span className={styles.tabIcon}>
{icon}
{showNotificationDot ? (
<span className={styles.notificationDot}>
<span className={styles.notificationDotPulse} />
</span>
) : null}
{showNotificationDot ? <NotificationDot /> : null}
</span>
<span>{label}</span>
</button>
@ -293,7 +291,7 @@ function FriendsPanel({
<FriendMenu key={friend.id} {...friend} onNavigate={onClose} />
))
) : (
<div className="text-lighter text-sm p-2">
<div className="text-lighter text-sm p-2 text-center">
{user
? t("front:sideNav.friends.noFriends")
: t("front:sideNav.friends.notLoggedIn")}
@ -305,6 +303,7 @@ function FriendsPanel({
onClick={onClose}
>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</MobilePanel>
);
@ -317,91 +316,19 @@ function TourneysPanel({
events: NonNullable<SidebarData>["events"];
onClose: () => void;
}) {
const { t, i18n } = useTranslation(["front"]);
const formatDayHeader = (date: Date) => {
if (isToday(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
});
const str = rtf.format(0, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
}
if (isTomorrow(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
});
const str = rtf.format(1, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
}
return date.toLocaleDateString(i18n.language, {
weekday: "long",
month: "short",
day: "numeric",
});
};
const formatTime = (date: Date) => {
return date.toLocaleTimeString(i18n.language, {
hour: "numeric",
minute: "2-digit",
});
};
const getDayKey = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toDateString();
};
const groupedEvents = events.reduce<Record<string, typeof events>>(
(acc, event) => {
const key = getDayKey(event.startTime);
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(event);
return acc;
},
{},
);
const dayKeys = Object.keys(groupedEvents);
const { t } = useTranslation(["front", "common"]);
return (
<MobilePanel title={t("front:sideNav.myCalendar")} onClose={onClose}>
{events.length > 0 ? (
dayKeys.map((dayKey) => {
const dayEvents = groupedEvents[dayKey];
const firstDate = new Date(dayEvents[0].startTime * 1000);
return (
<div key={dayKey}>
<div className={styles.dayHeader}>
{formatDayHeader(firstDate)}
</div>
{dayEvents.map((event) => (
<ListLink
key={`${event.type}-${event.id}`}
to={event.url}
imageUrl={event.logoUrl ?? undefined}
subtitle={formatTime(new Date(event.startTime * 1000))}
onClick={onClose}
>
{event.scrimStatus === "booked"
? t("front:sideNav.scrimVs", { opponent: event.name })
: event.scrimStatus === "looking"
? t("front:sideNav.lookingForScrim")
: event.name}
</ListLink>
))}
</div>
);
})
) : (
<div className="text-lighter text-sm p-2">
{t("front:sideNav.noEvents")}
</div>
)}
<EventsList events={events} onClick={onClose} />
<Link
to={EVENTS_PAGE}
className={styles.panelSectionLink}
onClick={onClose}
>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</MobilePanel>
);
}

View File

@ -0,0 +1,33 @@
.dot {
position: absolute;
top: var(--dot-top, -2px);
right: var(--dot-right, -2px);
width: 8px;
height: 8px;
background-color: var(--color-text-accent);
border-radius: 100%;
outline: 2px solid var(--color-bg);
pointer-events: none;
}
.pulse {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: var(--color-text-accent);
box-shadow: 0 0 0 var(--color-text-accent);
animation: pulse 2s infinite;
}
@keyframes pulse {
from {
box-shadow: 0 0 0 0 var(--color-text-accent);
}
70% {
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
}
to {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
}
}

View File

@ -0,0 +1,10 @@
import clsx from "clsx";
import styles from "./NotificationDot.module.css";
export function NotificationDot({ className }: { className?: string }) {
return (
<span className={clsx(styles.dot, className)}>
<span className={styles.pulse} />
</span>
);
}

View File

@ -28,6 +28,7 @@
align-items: center;
padding-inline: var(--s-2);
flex-shrink: 0;
overflow: hidden;
}
.sideNavTopCentered {
@ -121,35 +122,8 @@
}
.sideNavFooterUnseenDot {
background-color: var(--color-text-accent);
border-radius: 100%;
width: 8px;
height: 8px;
position: absolute;
top: 2px;
right: 2px;
outline: 2px solid var(--color-bg-high);
}
.sideNavFooterUnseenDotPulse {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: var(--color-text-accent);
box-shadow: 0 0 0 var(--color-text-accent);
animation: pulse 2s infinite;
}
@keyframes pulse {
from {
box-shadow: 0 0 0 0 inherit;
}
70% {
box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
}
to {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
}
--dot-top: 2px;
--dot-right: 2px;
}
.sideNavHeader {

View File

@ -121,7 +121,12 @@ export function ListLink({
{subtitle || badge ? (
<div className={styles.listLinkSubtitleRow}>
{subtitle ? (
<span className={styles.listLinkSubtitle}>{subtitle}</span>
<span
className={styles.listLinkSubtitle}
suppressHydrationWarning
>
{subtitle}
</span>
) : null}
{typeof badge === "string" ? (
<span

View File

@ -2,7 +2,6 @@ import { isToday, isTomorrow } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { SidebarStream } from "~/features/core/streams/streams.server";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { LanguageCode } from "~/modules/i18n/config";
import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates";
import { navIconUrl } from "~/utils/urls";
@ -19,7 +18,6 @@ export function StreamListItems({
onClick?: () => void;
}) {
const { t, i18n } = useTranslation(["front"]);
const isMounted = useIsMounted();
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
@ -60,7 +58,7 @@ export function StreamListItems({
const prevIsLive =
prevStream &&
databaseTimestampToDate(prevStream.startsAt).getTime() <= Date.now();
const showUpcomingDivider = isMounted && isUpcoming && prevIsLive;
const showUpcomingDivider = isUpcoming && prevIsLive;
return (
<React.Fragment key={stream.id}>
@ -85,22 +83,16 @@ export function StreamListItems({
</span>
) : stream.subtitle ? (
stream.subtitle
) : isMounted ? (
isUpcoming ? (
formatRelativeDate(stream.startsAt)
) : (
formatDistanceToNow(startsAtDate, {
addSuffix: true,
language: i18n.language as LanguageCode,
})
)
) : isUpcoming ? (
formatRelativeDate(stream.startsAt)
) : (
""
formatDistanceToNow(startsAtDate, {
addSuffix: true,
language: i18n.language as LanguageCode,
})
)
}
badge={
isMounted && !isUpcoming ? "LIVE" : streamTierBadge(stream)
}
badge={!isUpcoming ? "LIVE" : streamTierBadge(stream)}
onClick={onClick}
>
{stream.name}

View File

@ -139,7 +139,7 @@ export function WeaponSelect<
? specialWeaponImageUrl(TRIZOOKA_ID)
: weaponCategoryUrl(name)
}
className={idx === 0 ? "pt-0-5-forced" : undefined}
className={idx === 0 ? "pt-0-5" : undefined}
key={key}
>
{weapons.map(({ weapon, name }) => (

View File

@ -44,3 +44,32 @@
.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: var(--s-1) var(--s-3);
background-color: var(--color-bg-higher);
border-radius: var(--radius-field);
& svg {
stroke-width: 3;
}
}
@media screen and (max-width: 599px) {
.viewAllLink {
margin-top: var(--s-4);
}
}
.viewAllLink:hover {
color: var(--color-text);
background-color: var(--color-bg-high);
}

View File

@ -1,7 +1,7 @@
import { Bell, RefreshCcw } from "lucide-react";
import { Bell, ChevronRight, RefreshCcw } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMatches, useRevalidator } from "react-router";
import { Link, useMatches, useRevalidator } from "react-router";
import {
NotificationItem,
NotificationItemDivider,
@ -11,7 +11,7 @@ import { NOTIFICATIONS } from "~/features/notifications/notifications-contants";
import type { RootLoaderData } from "~/root";
import { NOTIFICATIONS_URL } from "~/utils/urls";
import { useMarkNotificationsAsSeen } from "../../features/notifications/notifications-hooks";
import { LinkButton, SendouButton } from "../elements/Button";
import { SendouButton } from "../elements/Button";
import styles from "./NotificationPopover.module.css";
@ -95,15 +95,15 @@ function NotificationsFooter({ onClose }: { onClose?: () => void }) {
return (
<div>
<hr className={styles.divider} />
<LinkButton
variant="minimal"
size="small"
<Link
to={NOTIFICATIONS_URL}
testId="notifications-see-all-button"
className={styles.viewAllLink}
data-testid="notifications-see-all-button"
onClick={onClose}
>
{t("common:notifications.seeAll")}
</LinkButton>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</div>
);
}

View File

@ -18,12 +18,18 @@
z-index: 10;
}
.siteTitleFlipper {
min-width: 0;
overflow: hidden;
}
.siteTitle {
display: flex;
align-items: center;
gap: var(--s-2);
height: 100%;
min-width: 0;
overflow: hidden;
}
.siteLogo {
@ -101,6 +107,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
animation: fadeInFull 200ms ease-out 150ms both;
}
@ -135,6 +142,12 @@
}
}
.sideNavCollapseButtonContainer {
position: relative;
--dot-top: 2px;
--dot-right: 2px;
}
/* Doubled selector increases specificity to beat Button.module.css's display: flex */
.sideNavCollapseButton.sideNavCollapseButton,
.sideNavModalTrigger.sideNavModalTrigger {

View File

@ -25,12 +25,18 @@ import { useUser } from "~/features/auth/core/user";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import { FRIENDS_PAGE, SETTINGS_PAGE, userPage } from "~/utils/urls";
import {
EVENTS_PAGE,
FRIENDS_PAGE,
SETTINGS_PAGE,
userPage,
} from "~/utils/urls";
import { Avatar } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
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";
@ -188,6 +194,7 @@ export function Layout({
}, []);
const user = useUser();
const { unseenIds } = useNotifications();
const sidebarData = data?.sidebar;
const events = sidebarData?.events ?? [];
const friends = sidebarData?.friends ?? [];
@ -208,7 +215,17 @@ export function Layout({
const sideNavChildren = (
<>
<SideNavHeader icon={<Calendar />}>
<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 ? (
@ -309,6 +326,7 @@ export function Layout({
<SideNavCollapseButton
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
className={styles.sideNavCollapseButton}
showNotificationDot={sideNavCollapsed && unseenIds.length > 0}
/>
<TopNavMenus />
<TopRightButtons
@ -337,7 +355,10 @@ function SiteTitle() {
const hasBreadcrumbs = breadcrumbs.length > 0;
return (
<Flipper flipKey={isFrontPage ? "front" : "other"}>
<Flipper
flipKey={isFrontPage ? "front" : "other"}
className={styles.siteTitleFlipper}
>
<div className={styles.siteTitle}>
<Flipped flipId="site-logo">
<Link to="/" className={styles.siteLogo}>
@ -376,19 +397,24 @@ function SiteLogoContent() {
function SideNavCollapseButton({
onToggle,
className,
showNotificationDot,
}: {
onToggle?: () => void;
className?: string;
showNotificationDot?: boolean;
}) {
return (
<SendouButton
className={className}
variant="minimal"
size="small"
shape="square"
icon={<PanelLeft />}
onPress={onToggle}
/>
<div className={styles.sideNavCollapseButtonContainer}>
<SendouButton
className={className}
variant="minimal"
size="small"
shape="square"
icon={<PanelLeft />}
onPress={onToggle}
/>
{showNotificationDot ? <NotificationDot /> : null}
</div>
);
}
@ -419,6 +445,7 @@ function MyRampUnit() {
function SideNavUserPanel() {
const { t } = useTranslation();
const location = useLocation();
const user = useUser();
const { notifications, unseenIds } = useNotifications();
@ -433,11 +460,14 @@ function SideNavUserPanel() {
</Link>
<div className={sideNavStyles.sideNavFooterActions}>
{notifications ? (
<div className={sideNavStyles.sideNavFooterNotification}>
<div
className={sideNavStyles.sideNavFooterNotification}
key={location.pathname}
>
{unseenIds.length > 0 ? (
<div className={sideNavStyles.sideNavFooterUnseenDot}>
<div className={sideNavStyles.sideNavFooterUnseenDotPulse} />
</div>
<NotificationDot
className={sideNavStyles.sideNavFooterUnseenDot}
/>
) : null}
<SendouPopover
trigger={

View File

@ -237,7 +237,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
variation === "NO_SCRIMS" ? undefined : scrimPostRequests,
associations,
notifications,
friendships,
() => friendships(variation),
liveStreams,
];
@ -2800,7 +2800,7 @@ async function organization() {
const SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS = [150, 151, 152, 153];
const SENDOU_FRIEND_IDS_AS_TOURNAMENT_SUBS = [100, 101, 102, 103];
async function friendships() {
async function friendships(variation?: SeedVariation | null) {
const allFriendIds = [
...SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS,
...SENDOU_FRIEND_IDS_AS_TOURNAMENT_SUBS,
@ -2820,6 +2820,8 @@ async function friendships() {
.run({ userOneId, userTwoId });
}
if (variation === "NO_SQ_GROUPS") return;
for (const friendId of SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS) {
const group = await SQGroupRepository.createGroup({
status: "ACTIVE",

View File

@ -7,7 +7,6 @@ import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
ARTICLES_MAIN_PAGE,
articlePage,
articlePreviewUrl,
navIconUrl,
} from "~/utils/urls";
@ -28,11 +27,6 @@ export const handle: SendouRouteHandle = {
href: ARTICLES_MAIN_PAGE,
type: "IMAGE",
},
{
text: data.title,
href: articlePage(data.slug),
type: "TEXT",
},
];
},
};
@ -64,12 +58,38 @@ export default function ArticlePage() {
<div className="text-sm text-lighter">
by <Author /> <time>{data.dateString}</time>
</div>
<Markdown>{data.content}</Markdown>
<Markdown>
{contentWithoutLeadingTitle(data.content, data.title)}
</Markdown>
</article>
</Main>
);
}
function normalizeText(text: string) {
return text
.replace(/\*+/g, "")
.replace(/…/g, "...")
.replace(/\\!/g, "!")
.trim();
}
function contentWithoutLeadingTitle(content: string, title: string) {
const trimmed = content.trimStart();
const firstLineEnd = trimmed.indexOf("\n");
const firstLine =
firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd);
if (
firstLine.startsWith("# ") &&
normalizeText(firstLine.slice(2)) === normalizeText(title)
) {
return trimmed.slice(firstLine.length).trimStart();
}
return content;
}
function Author() {
const data = useLoaderData<typeof loader>();

View File

@ -25,7 +25,6 @@ export default function EditBadgePage() {
<SendouDialog
heading={`Editing winners of ${badge.displayName}`}
onCloseTo={parentMatch.pathname}
isFullScreen
>
<Form method="post" className="stack md">
{isStaff ? <Managers data={data} /> : null}
@ -53,13 +52,13 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
).length;
return (
<div className="stack md mx-auto">
<div className="stack md">
<div className="stack sm">
<h3 className={styles.editSmallHeader}>Managers</h3>
<UserSearch
key={managers.map((m) => m.id).join("-")}
label="Add new manager"
className="text-center mx-auto"
className="text-center"
name="new-manager"
onChange={(user) => {
if (!user) return;
@ -134,12 +133,12 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
const userInputKey = owners.map((o) => `${o.id}-${o.count}`).join("-");
return (
<div className="stack md mx-auto">
<div className="stack md">
<div className="stack sm">
<h3 className={styles.editSmallHeader}>Owners</h3>
<UserSearch
label="Add new owner"
className="text-center mx-auto"
className="text-center"
name="new-owner"
key={userInputKey}
onChange={(user) => {

View File

@ -45,11 +45,6 @@ export const handle: SendouRouteHandle = {
href: weaponBuildPage(data.meta.slug),
type: "IMAGE",
},
{
href: "/",
text: data.meta.breadcrumbText,
type: "TEXT",
},
];
},
};

View File

@ -48,11 +48,6 @@ export const handle: SendouRouteHandle = {
href: weaponBuildPage(data.meta.slug),
type: "IMAGE",
},
{
href: "/",
text: data.meta.breadcrumbText,
type: "TEXT",
},
];
},
};

View File

@ -0,0 +1,32 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import {
scrimToSidebarEvent,
tournamentToSidebarEvent,
} from "~/features/sidebar/core/sidebar.server";
export type EventsLoaderData = typeof loader;
export const loader = async () => {
const user = requireUser();
const [tournamentsData, scrimsData] = await Promise.all([
ShowcaseTournaments.frontPageTournamentsByUserId(user.id),
ScrimPostRepository.findUserScrims(user.id),
]);
const registered = tournamentsData.participatingFor
.map(tournamentToSidebarEvent)
.sort((a, b) => a.startTime - b.startTime);
const hosting = tournamentsData.organizingFor
.map(tournamentToSidebarEvent)
.sort((a, b) => a.startTime - b.startTime);
const scrims = scrimsData
.map(scrimToSidebarEvent)
.sort((a, b) => a.startTime - b.startTime);
return { registered, hosting, scrims };
};

View File

@ -0,0 +1,22 @@
.eventsListHeader {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--s-2);
margin-block-end: var(--s-2);
}
.filterRadio {
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
cursor: pointer;
color: var(--color-text-high);
font-weight: var(--weight-semi);
font-size: var(--font-3xs);
}
.filterRadioSelected {
background-color: var(--color-bg-higher);
color: var(--color-text);
}

View File

@ -0,0 +1,83 @@
import clsx from "clsx";
import { useState } from "react";
import { Radio, RadioGroup } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import { EventsList } from "~/components/EventsList";
import { Main } from "~/components/Main";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { CALENDAR_PAGE } from "~/utils/urls";
import type { EventsLoaderData } from "../loaders/events.server";
import styles from "./events.module.css";
export { loader } from "../loaders/events.server";
export const handle: SendouRouteHandle = {
i18n: ["calendar"],
};
type ViewFilter = "registered" | "hosting" | "scrims";
export default function EventsPage() {
const { t } = useTranslation(["calendar"]);
const data = useLoaderData<EventsLoaderData>();
const [filter, setFilter] = useState<ViewFilter>("registered");
const viewLabels: Record<ViewFilter, string> = {
registered: t("calendar:events.view.registered"),
hosting: t("calendar:events.view.hosting"),
scrims: t("calendar:events.view.scrims"),
};
const shownEvents =
filter === "registered"
? data.registered
: filter === "hosting"
? data.hosting
: data.scrims;
const hasNoEventsAtAll =
data.registered.length === 0 &&
data.hosting.length === 0 &&
data.scrims.length === 0;
return (
<Main halfWidth>
<div className={styles.eventsListHeader}>
<h2 className="text-lg mx-2">{t("calendar:events.title")}</h2>
{hasNoEventsAtAll ? null : (
<RadioGroup
value={filter}
onChange={(v) => setFilter(v as ViewFilter)}
orientation="horizontal"
className="stack horizontal xs"
>
{(["registered", "hosting", "scrims"] as const).map((value) => (
<Radio key={value} value={value}>
{({ isSelected }) => (
<span
className={clsx(styles.filterRadio, {
[styles.filterRadioSelected]: isSelected,
})}
>
{viewLabels[value]}
</span>
)}
</Radio>
))}
</RadioGroup>
)}
</div>
{hasNoEventsAtAll ? (
<p className="text-lighter text-sm">
{t("calendar:events.emptyAll")}{" "}
<Link to={CALENDAR_PAGE}>{t("calendar:events.findOnCalendar")}</Link>
</p>
) : shownEvents.length === 0 ? (
<p className="text-lighter text-sm">{t("calendar:events.empty")}</p>
) : (
<EventsList events={shownEvents} />
)}
</Main>
);
}

View File

@ -44,10 +44,10 @@ export function getLiveTournamentStreams(): SidebarStream[] {
return streams;
}
// xxx: not always reporting furthest round
function deriveCurrentRound(tournament: Tournament): string {
for (const bracket of tournament.brackets) {
for (const bracket of tournament.brackets.toReversed()) {
if (bracket.preview) continue;
if (bracket.isUnderground) continue;
for (const match of bracket.data.match) {
const isActive =

View File

@ -12,6 +12,7 @@ import * as FriendRepository from "~/features/friends/FriendRepository.server";
import { resolveFriendActivity } from "~/features/friends/friends-utils.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
import type { SidebarScrim } from "~/features/scrims/ScrimPostRepository.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
@ -81,27 +82,9 @@ export async function resolveSidebarData(userId: number | null) {
seenTournamentIds.add(t.id);
return true;
})
.map((t) => ({
id: t.id,
name: t.name,
url: t.url,
logoUrl: t.logoUrl,
startTime: t.startTime,
type: "tournament" as const,
}));
.map(tournamentToSidebarEvent);
const scrimsIconUrl = `${navIconUrl("scrims")}.png`;
const scrimEvents: SidebarEvent[] = scrimsData.map((s) => ({
id: s.id,
name: s.opponentName ?? "Scrim",
url: s.isAccepted
? href("/scrims/:id", { id: String(s.id) })
: href("/scrims"),
logoUrl: s.opponentAvatarUrl ?? scrimsIconUrl,
startTime: s.at,
type: "scrim" as const,
scrimStatus: s.isAccepted ? ("booked" as const) : ("looking" as const),
}));
const scrimEvents: SidebarEvent[] = scrimsData.map(scrimToSidebarEvent);
const personalEvents = [...tournamentEvents, ...scrimEvents].sort(
(a, b) => a.startTime - b.startTime,
@ -347,3 +330,32 @@ function rowToSidebarFriend(
tournamentId: row.tournamentId,
};
}
export function tournamentToSidebarEvent(
t: ShowcaseCalendarEvent,
): SidebarEvent {
return {
id: t.id,
name: t.name,
url: t.url,
logoUrl: t.logoUrl,
startTime: t.startTime,
type: "tournament" as const,
};
}
const SCRIMS_ICON_URL = `${navIconUrl("scrims")}.png`;
export function scrimToSidebarEvent(s: SidebarScrim): SidebarEvent {
return {
id: s.id,
name: s.opponentName ?? "Scrim",
url: s.isAccepted
? href("/scrims/:id", { id: String(s.id) })
: href("/scrims"),
logoUrl: s.opponentAvatarUrl ?? SCRIMS_ICON_URL,
startTime: s.at,
type: "scrim" as const,
scrimStatus: s.isAccepted ? ("booked" as const) : ("looking" as const),
};
}

View File

@ -8,7 +8,7 @@ import {
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { mySlugify, TEAM_SEARCH_PAGE, teamPage } from "~/utils/urls";
import { mySlugify, teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import {
@ -60,7 +60,7 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "DELETE_TEAM": {
await TeamRepository.del(team.id);
throw redirect(TEAM_SEARCH_PAGE);
throw redirect("/");
}
case "DELETE_AVATAR": {
await TeamRepository.removeTeamImage(team.id, "avatar");

View File

@ -15,7 +15,7 @@ type Theme = (typeof Theme)[keyof typeof Theme];
const themes = Object.values(Theme);
type ThemeContextType = {
htmlThemeClass: Theme;
htmlThemeClass: Theme | "";
metaColorScheme: "light dark" | "dark light";
userTheme: Theme | "auto" | null;
setUserTheme: (newTheme: Theme | "auto") => void;
@ -39,7 +39,7 @@ function useSystemTheme() {
return useSyncExternalStore(
subscribeToSystemTheme,
getSystemTheme,
() => Theme.DARK,
() => null,
);
}
@ -49,7 +49,7 @@ type ThemeProviderProps = {
themeSource: "user-preference" | "static";
};
function colorScheme(theme: Theme) {
function colorScheme(theme: Theme | "") {
return theme === Theme.LIGHT ? "light dark" : "dark light";
}
@ -66,10 +66,10 @@ function ThemeProvider({
const systemTheme = useSystemTheme();
const persistThemeFetcher = useFetcher();
const resolvedTheme = isStatic
const resolvedTheme: Theme | "" = isStatic
? (specifiedTheme ?? Theme.DARK)
: userPreference === "auto"
? systemTheme
? (systemTheme ?? "")
: userPreference;
const handleSetUserTheme = (newTheme: Theme | "auto") => {

View File

@ -238,7 +238,7 @@ function MatchRow({
>
<div
className={clsx(styles.matchSeed, {
"text-lighter-important italic opaque": simulated,
"text-lighter italic opaque": simulated,
[styles.matchSeedWide]: isBigSeedNumber,
})}
>

View File

@ -439,7 +439,7 @@ function RegistrationProgress({
</h3>
<section className={clsx(styles.section, "stack md")}>
<div className="stack horizontal lg justify-center text-sm font-semi-bold">
{steps.map((step) => {
{steps.map((step, i) => {
return (
<div
key={step.name}
@ -449,6 +449,7 @@ function RegistrationProgress({
{step.status === "completed" ? (
<Check
className={clsx(styles.sectionIcon, "color-success")}
data-testid={`checkmark-icon-num-${i + 1}`}
/>
) : step.status === "notice" ? (
<AlertCircle
@ -1045,7 +1046,7 @@ function FillRoster({
) : null}
</section>
{tournament.ctx.settings.requireInGameNames ? (
<div className={clsx(styles.sectionWarning, "text-warning-important")}>
<div className={clsx(styles.sectionWarning, "text-warning")}>
Note that you are expected to use the in-game names as listed above.
Playing in the event with a different name or using the alias feature
might result in disqualification.

View File

@ -19,7 +19,6 @@ import { removeMarkdown } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
tournamentDivisionsPage,
tournamentOrganizationPage,
tournamentPage,
tournamentRegisterPage,
} from "~/utils/urls";
@ -70,24 +69,13 @@ export const handle: SendouRouteHandle = {
const data = JSON.parse(rawData) as TournamentLoaderData;
return [
data.tournament.ctx.organization?.logoUrl
? {
imgPath: data.tournament.ctx.organization.logoUrl,
href: tournamentOrganizationPage({
organizationSlug: data.tournament.ctx.organization.slug,
}),
type: "IMAGE" as const,
text: "",
rounded: true,
}
: null,
{
imgPath: data.tournament.ctx.logoUrl,
href: tournamentPage(data.tournament.ctx.id),
type: "IMAGE" as const,
text: data.tournament.ctx.name,
},
].filter((crumb) => crumb !== null);
];
},
};

View File

@ -8,6 +8,7 @@ import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
discordAvatarUrl,
userAdminPage,
userArtPage,
userBuildsPage,
@ -44,10 +45,23 @@ export const handle: SendouRouteHandle = {
if (!data) return [];
if (!data.user.discordAvatar) {
return {
text: data.user.username,
href: userPage(data.user),
type: "TEXT",
};
}
return {
text: data.user.username,
imgPath: discordAvatarUrl({
discordId: data.user.discordId,
discordAvatar: data.user.discordAvatar,
size: "sm",
}),
href: userPage(data.user),
type: "TEXT",
type: "IMAGE",
text: data.user.username,
};
},
};

View File

@ -164,6 +164,7 @@ function Document({
dir={i18n.dir()}
className={clsx(htmlThemeClass, "scrollbar")}
style={Object.fromEntries(customThemeStyle)}
suppressHydrationWarning
>
<head>
<meta charSet="utf-8" />

View File

@ -44,6 +44,8 @@ export default [
route("/friends", "features/friends/routes/friends.tsx"),
route("/events", "features/calendar/routes/events.tsx"),
route("/suspended", "features/ban/routes/suspended.tsx"),
route("/u", "features/user-search/routes/u.tsx"),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,214 +1,217 @@
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
@layer reset {
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
/*
/*
Document
========
*/
/**
/**
Use a better box model (opinionated).
*/
*,
::before,
::after {
box-sizing: border-box;
}
*,
::before,
::after {
box-sizing: border-box;
}
/**
/**
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
2. Correct the line height in all browsers.
3. Prevent adjustments of font size after orientation changes in iOS.
4. Use a more readable tab size (opinionated).
*/
html {
font-family:
system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji"; /* 1 */
line-height: 1.15; /* 2 */
-webkit-text-size-adjust: 100%; /* 3 */
tab-size: 4; /* 4 */
}
html {
font-family:
system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji"; /* 1 */
line-height: 1.15; /* 2 */
-webkit-text-size-adjust: 100%; /* 3 */
tab-size: 4; /* 4 */
}
/*
/*
Sections
========
*/
/**
/**
Remove the margin in all browsers.
*/
body {
margin: 0;
}
body {
margin: 0;
}
/*
/*
Text-level semantics
====================
*/
/**
/**
Add the correct font weight in Chrome and Safari.
*/
b,
strong {
font-weight: bolder;
}
b,
strong {
font-weight: bolder;
}
/**
/**
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
2. Correct the odd 'em' font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family:
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; /* 1 */
font-size: 1em; /* 2 */
}
code,
kbd,
samp,
pre {
font-family:
ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
/**
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
small {
font-size: 80%;
}
/**
/**
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
sup {
top: -0.5em;
}
/*
/*
Tabular data
============
*/
/**
/**
Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
*/
table {
border-color: currentcolor;
}
table {
border-color: currentcolor;
}
/*
/*
Forms
=====
*/
/**
/**
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
/**
Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
appearance: button;
-webkit-appearance: button;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
appearance: button;
-webkit-appearance: button;
}
/**
/**
Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
*/
legend {
padding: 0;
}
legend {
padding: 0;
}
/**
/**
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
progress {
vertical-align: baseline;
}
/**
/**
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/**
/**
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type="search"] {
appearance: textfield; /* 1 */
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
[type="search"] {
appearance: textfield; /* 1 */
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
/**
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
/**
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to 'inherit' in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/*
/*
Interactive
===========
*/
/*
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
summary {
display: list-item;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,8 @@
/*
@layer reset, base, components, utilities;
/*
Make sure to read styles.md before editing colors in this file,
Make sure to read styles.md before editing colors in this file,
it contains important information about how the styles work and how to edit them properly
*/

View File

@ -120,6 +120,7 @@ export const SENDOUQ_STREAMS_PAGE = "/q/streams";
export const TIERS_PAGE = "/tiers";
export const SUSPENDED_PAGE = "/suspended";
export const LFG_PAGE = "/lfg";
export const EVENTS_PAGE = "/events";
export const FRIENDS_PAGE = "/friends";
export const SETTINGS_PAGE = "/settings";
export const LUTI_PAGE = "/luti";

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -12,33 +12,9 @@ import {
test,
} from "~/utils/playwright";
import { createFormHelpers } from "~/utils/playwright-form";
import {
editTeamPage,
TEAM_SEARCH_PAGE,
teamPage,
userPage,
} from "~/utils/urls";
test.describe("Team search page", () => {
test("filters teams", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({ page, url: TEAM_SEARCH_PAGE });
const searchInput = page.getByTestId("team-search-input");
const firstTeamName = page.getByTestId("team-0");
const secondTeamName = page.getByTestId("team-1");
await expect(firstTeamName).toHaveText("Alliance Rogue");
await expect(secondTeamName).toBeVisible();
await searchInput.fill("Alliance Rogue");
await expect(secondTeamName).not.toBeVisible();
await firstTeamName.click();
await expect(page).toHaveURL(/alliance-rogue/);
});
import { editTeamPage, teamPage, userPage } from "~/utils/urls";
test.describe("New team creation", () => {
test("creates new team", async ({ page }) => {
await seed(page);
await impersonate(page, NZAP_TEST_ID);
@ -47,7 +23,7 @@ test.describe("Team search page", () => {
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("menu-item-team").click();
await expect(page).toHaveURL(/new=true/);
await expect(page).toHaveURL(/t\/new/);
const form = createFormHelpers(page, createTeamSchema);
await form.fill("name", "Chimera");
@ -55,25 +31,6 @@ test.describe("Team search page", () => {
await expect(page).toHaveURL(/chimera/);
});
test("filters teams by tag & displays tag", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("alliance-rogue") });
await page.getByTestId("edit-team-button").click();
await page.getByLabel("Tag").fill("AR");
await submit(page, "edit-team-submit-button");
await navigate({ page, url: TEAM_SEARCH_PAGE });
const searchInput = page.getByTestId("team-search-input");
await searchInput.fill("ar");
const firstTeamName = page.getByTestId("team-0");
await expect(firstTeamName).toContainText("Alliance Rogue");
await expect(firstTeamName).toContainText("AR");
});
});
test.describe("Team page", () => {
@ -129,16 +86,13 @@ test.describe("Team page", () => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: TEAM_SEARCH_PAGE });
const firstTeamName = page.getByTestId("team-0");
await firstTeamName.click();
await navigate({ page, url: teamPage("alliance-rogue") });
await page.getByTestId("edit-team-button").click();
await page.getByTestId("delete-team-button").click();
await modalClickConfirmButton(page);
await expect(page).toHaveURL(TEAM_SEARCH_PAGE);
await expect(page.getByTestId("team-0")).not.toHaveText("Alliance Rogue");
await expect(page).not.toHaveURL(/alliance-rogue/);
});
test("resets invite code, joins team, leaves, rejoins", async ({ page }) => {

View File

@ -99,7 +99,8 @@ test.describe("Tournament streams", () => {
await backToBracket(page);
// The LIVE button should be visible since team 102 members are streaming
const liveButton = page.getByText("LIVE").first();
const bracketsViewer = page.getByTestId("brackets-viewer");
const liveButton = bracketsViewer.getByText("LIVE").first();
await expect(liveButton).toBeVisible();
// Click the LIVE button to open the popover

View File

@ -16,8 +16,6 @@ import { userEditProfilePage, userPage } from "~/utils/urls";
const goToEditPage = (page: Page) =>
page.getByText("Edit", { exact: true }).click();
const submitEditForm = (page: Page) =>
page.getByText("Save", { exact: true }).click();
test.describe("User page", () => {
test("uses badge pagination", async ({ page }) => {
@ -115,54 +113,38 @@ test.describe("User page", () => {
await page.getByText("Stick 0 / Motion -5").isVisible();
});
test("customizes user page colors and resets them", async ({ page }) => {
test("customizes theme colors and resets them", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: userPage({ discordId: ADMIN_DISCORD_ID, customUrl: "sendou" }),
});
const body = page.locator("body");
const bodyColor = () =>
body.evaluate((element) =>
window.getComputedStyle(element).getPropertyValue("--bg").trim(),
const htmlElement = page.locator("html");
const hasCustomTheme = () =>
htmlElement.evaluate(
(el) => el.style.getPropertyValue("--_base-h") !== "",
);
await expect(bodyColor()).resolves.toMatch(/#ebebf0/);
await navigate({ page, url: "/settings" });
await goToEditPage(page);
// initially no custom theme
await expect(hasCustomTheme()).resolves.toBe(false);
await page.locator("span").filter({ hasText: "Custom colors" }).click();
// change the base hue slider
const baseHueSlider = page.locator("#base-hue");
await baseHueSlider.fill("120");
await page.getByTestId("color-input-bg").fill("#4a412a");
// also test filling this because it's a special case as it also changes bg-lightest
await page.getByTestId("color-input-bg-lighter").fill("#4a412a");
await submitEditForm(page);
// got redirected
await expect(page).not.toHaveURL(/edit/);
// save
await page.getByRole("button", { name: "Save" }).first().click();
await page.reload();
await expect(bodyColor()).resolves.toMatch(/#4a412a/);
// then lets test resetting the colors is possible
await goToEditPage(page);
await page.locator("span").filter({ hasText: "Custom colors" }).click();
// verify custom theme was applied
await expect(hasCustomTheme()).resolves.toBe(true);
for (const button of await page
.getByRole("button", { name: "Reset" })
.all()) {
await button.click();
}
await submitEditForm(page);
// got redirected
await expect(page).not.toHaveURL(/edit/);
// reset
await page.getByRole("button", { name: "Reset" }).first().click();
await page.reload();
await expect(bodyColor()).resolves.toMatch(/#ebebf0/);
// verify custom theme was removed
await expect(hasCustomTheme()).resolves.toBe(false);
});
test("edits weapon pool", async ({ page }) => {

View File

@ -79,5 +79,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -79,5 +79,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -79,5 +79,12 @@
"filter.applyAndDefault": "Apply & make default",
"forms.draft": "Draft",
"forms.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.",
"forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket."
"forms.draftBracketStartBlocked": "Tournament is in draft mode. Edit the tournament and disable the draft toggle before starting the bracket.",
"events.title": "My Events",
"events.view.registered": "Registered",
"events.view.hosting": "Hosting",
"events.view.scrims": "Scrims",
"events.empty": "No events in this category",
"events.emptyAll": "You have no upcoming events.",
"events.findOnCalendar": "Find an event to join on the calendar!"
}

View File

@ -81,5 +81,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -81,5 +81,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -81,5 +81,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -81,5 +81,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -81,5 +81,12 @@
"filter.applyAndDefault": "החל והפוך לברירת מחדל",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -81,5 +81,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -75,5 +75,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -75,5 +75,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -79,5 +79,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -83,5 +83,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -81,5 +81,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -83,5 +83,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -75,5 +75,12 @@
"filter.applyAndDefault": "",
"forms.draft": "",
"forms.draftInfo": "",
"forms.draftBracketStartBlocked": ""
"forms.draftBracketStartBlocked": "",
"events.title": "",
"events.view.registered": "",
"events.view.hosting": "",
"events.view.scrims": "",
"events.empty": "",
"events.emptyAll": "",
"events.findOnCalendar": ""
}

View File

@ -19,6 +19,20 @@ export default defineConfig(({ mode }) => {
},
},
plugins: [
{
// Wraps CSS modules in @layer components so utility classes always win.
// The layer order declaration is prepended to each module because in Vite
// dev mode, module <style> tags are injected before global stylesheets —
// without it the implicit first @layer components would get lowest priority.
name: "css-modules-layer",
enforce: "pre",
transform(code, id) {
if (!id.endsWith(".module.css")) return;
return {
code: `@layer reset, base, components, utilities;\n@layer components {\n${code}\n}`,
};
},
},
reactRouter(),
babel({
filter: /\.[jt]sx?$/,