* Remove light mode

* Trim header

* New front page initial

* Get rid of build layout

* Breadcrumbs

* Desktop side nav

* Overhaul colors

* Add breadcrumbs

* New sub nav style

* Front page action buttons

* Add back add new build button

* Add articles page with icon

* Minor Object damage page layout tweaks

* Remove one unnecessary render from object damage

* Fix wrong link in article page

* Profile -> My Page in header

* Log in/out buttons in front

* Add drawings to front page

* Remove unnecessary comment
This commit is contained in:
Kalle 2022-12-05 16:05:51 +02:00 committed by GitHub
parent dacc475efb
commit 34ca290bdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
94 changed files with 1363 additions and 1824 deletions

View File

@ -17,6 +17,7 @@ export function Ability({
dropAllowed = false,
onClick,
onDrop,
className,
}: {
ability: AbilityWithUnknown;
size: keyof typeof sizeMap;
@ -24,6 +25,7 @@ export function Ability({
dropAllowed?: boolean;
onClick?: () => void;
onDrop?: (event: React.DragEvent) => void;
className?: string;
}) {
const sizeNumber = sizeMap[size];
@ -45,12 +47,16 @@ export function Ability({
return (
<AbilityTag
className={clsx("build__ability", {
"is-drag-target": isDragTarget,
"drag-started": dragStarted,
"drop-allowed": dropAllowed,
readonly,
})}
className={clsx(
"build__ability",
{
"is-drag-target": isDragTarget,
"drag-started": dragStarted,
"drop-allowed": dropAllowed,
readonly,
},
className
)}
style={
{
"--ability-size": `${sizeNumber}px`,

View File

@ -1,58 +0,0 @@
import { Link, useMatches } from "@remix-run/react";
import { useMemo, Fragment } from "react";
import { useTranslation } from "~/hooks/useTranslation";
import { isDefined } from "~/utils/arrays";
import { type SendouRouteHandle } from "~/utils/remix";
type Crumb = {
path: string;
name: string;
};
function useBreadcrumbs(): Crumb[] {
const matches = useMatches();
const { t } = useTranslation("common");
return useMemo(
() =>
matches
.map((match) => {
const handle = match.handle as undefined | SendouRouteHandle;
const name = handle?.breadcrumb?.({ match, t });
return name ? { path: match.pathname, name } : undefined;
})
.filter(isDefined),
[matches, t]
);
}
export function Breadcrumbs() {
const breadcrumbs = useBreadcrumbs();
const showBreadcrumbs = breadcrumbs.length > 0;
if (!showBreadcrumbs) {
return null;
}
return (
<nav className="breadcrumbs">
{breadcrumbs.map((crumb, i) => {
const isLast = i === breadcrumbs.length - 1;
if (isLast) {
return <div key={crumb.path}>{crumb.name}</div>;
}
return (
<Fragment key={crumb.path}>
<div>
<Link to={crumb.path}>{crumb.name}</Link>
</div>
<div>/</div>
</Fragment>
);
})}
</nav>
);
}

View File

@ -6,21 +6,22 @@ export function Image({
width,
height,
style,
containerClassName,
}: {
path: string;
alt: string;
title?: string;
className?: string;
containerClassName?: string;
width?: number;
height?: number;
style?: React.CSSProperties;
}) {
return (
<picture title={title}>
<picture title={title} className={containerClassName}>
<source
type="image/avif"
srcSet={`${path}.avif`}
className={className}
width={width}
height={height}
style={style}

View File

@ -2,7 +2,6 @@ import { NavLink } from "@remix-run/react";
import type { LinkProps } from "@remix-run/react";
import clsx from "clsx";
import type * as React from "react";
import { ArrowUpIcon } from "./icons/ArrowUp";
export function SubNav({ children }: { children: React.ReactNode }) {
return (
@ -21,8 +20,7 @@ export function SubNavLink({
}) {
return (
<NavLink className={clsx("sub-nav__link", className)} end {...props}>
<span className="sub-nav__link__text">{children}</span>
<ArrowUpIcon className="sub-nav__active-icon" />
{children}
</NavLink>
);
}

View File

@ -1,9 +1,11 @@
export function GlobeIcon({
className,
alt,
size,
}: {
className?: string;
alt: string;
size?: number;
}) {
return (
<svg
@ -16,6 +18,8 @@ export function GlobeIcon({
role="img"
aria-hidden={alt === ""}
aria-label={alt !== "" ? alt : undefined}
width={size}
height={size}
>
{alt !== "" && <title>{alt}</title>}
<path

View File

@ -1,26 +1,25 @@
import type { CSSProperties } from "react";
export function ArrowUpIcon({
export function LogInIcon({
className,
style,
size,
}: {
className?: string;
style?: CSSProperties;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
width={size}
height={size}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/>
</svg>
);

View File

@ -1,28 +0,0 @@
export function MoonIcon({
className,
alt,
}: {
className?: string;
alt: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
role="img"
aria-hidden={alt === ""}
aria-label={alt !== "" ? alt : undefined}
>
{alt !== "" && <title>{alt}</title>}
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
);
}

View File

@ -1,29 +0,0 @@
export function SunIcon({
className,
alt,
}: {
className?: string;
alt: string;
title?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
role="img"
aria-hidden={alt === ""}
aria-label={alt !== "" ? alt : undefined}
>
{alt !== "" && <title>{alt}</title>}
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
}

View File

@ -1,28 +0,0 @@
export function SunAndMoonIcon({
className,
alt,
}: {
className?: string;
alt?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
role="img"
aria-hidden={alt === ""}
aria-label={alt !== "" ? alt : undefined}
>
{alt !== "" && <title>{alt}</title>}
<path
d="m19.921 16.252-1.4411-1.4841m-8.6467-8.9048-1.4411-1.4841m5.8557-0.26889 0.03042-2.0685m4.3307 3.9505 1.4841-1.4411m0.2689 5.8557 2.0685 0.03042m-10.747 2.2802c-3.2025-3.2981 1.7446-8.1018 4.9471-4.8037 3.2012 3.298-1.7459 8.1018-4.9471 4.8037zm3.233 5.4901a7.0662 7.0662 0 0 1-2.7676 0.28142c-3.8978-0.37316-6.7547-3.8351-6.3816-7.7328 0.092161-0.96268 0.3725-1.8613 0.80141-2.6639a7.0917 7.0917 0 0 0-4.9653 6.1002c-0.37316 3.8978 2.4838 7.3597 6.3816 7.7328a7.0917 7.0917 0 0 0 6.9314-3.7177z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -1,60 +0,0 @@
import clsx from "clsx";
export function HamburgerButton({
onClick,
expanded,
}: {
onClick: () => void;
expanded: boolean;
}) {
return (
<button
className="layout__burger"
onClick={onClick}
type="button"
aria-label={!expanded ? "Open menu" : "Close menu"}
>
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
className={clsx("layout__burger__top-line", {
expanded,
})}
x="6"
y="9"
width="20"
height="2"
rx="1"
fill="currentColor"
></rect>
<rect
className={clsx("layout__burger__middle-line", {
expanded,
})}
x="6"
y="15"
width="20"
height="2"
rx="1"
fill="currentColor"
></rect>
<rect
className={clsx("layout__burger__bottom-line", {
expanded,
})}
x="6"
y="21"
width="20"
height="2"
rx="1"
fill="currentColor"
></rect>
</svg>
</button>
);
}

View File

@ -1,4 +1,5 @@
import { useSearchParams } from "@remix-run/react";
import type * as React from "react";
import { useTranslation } from "~/hooks/useTranslation";
import { languages } from "~/modules/i18n";
import { LinkButton } from "../Button";
@ -16,19 +17,27 @@ const addUniqueParam = (
return paramsCopy;
};
export function LanguageChanger() {
export function LanguageChanger({
children,
plain = false,
}: {
children?: React.ReactNode;
plain?: boolean;
}) {
const { t, i18n } = useTranslation();
const [searchParams] = useSearchParams();
return (
<Popover
buttonChildren={
<GlobeIcon
alt={t("header.language")}
className="layout__header__button__icon"
/>
children ?? (
<GlobeIcon
alt={t("header.language")}
className="layout__header__button__icon"
/>
)
}
triggerClassName="layout__header__button"
triggerClassName={plain ? undefined : "layout__header__button"}
>
<div className="layout__user-popover">
{languages.map((lang) => (

View File

@ -0,0 +1,58 @@
import { useSearchParams } from "@remix-run/react";
import { useTranslation } from "~/hooks/useTranslation";
import { LOG_IN_URL } from "~/utils/urls";
import { Button } from "../Button";
import { Dialog } from "../Dialog";
export function LogInButtonContainer({
children,
}: {
children: React.ReactNode;
}) {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const authError = searchParams.get("authError");
const closeAuthErrorDialog = () => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.delete("authError");
setSearchParams(newSearchParams);
};
return (
<>
<form action={LOG_IN_URL} method="post">
{children}
</form>
{authError != null && (
<Dialog isOpen close={closeAuthErrorDialog}>
<div className="stack md">
<AuthenticationErrorHelp errorCode={authError} />
<Button onClick={closeAuthErrorDialog}>{t("actions.close")}</Button>
</div>
</Dialog>
)}
</>
);
}
function AuthenticationErrorHelp({ errorCode }: { errorCode: string }) {
const { t } = useTranslation();
switch (errorCode) {
case "aborted":
return (
<>
<h2 className="text-lg text-center">{t("auth.errors.aborted")}</h2>
{t("auth.errors.discordPermissions")}
</>
);
case "unknown":
default:
return (
<>
<h2 className="text-lg text-center">{t("auth.errors.failed")}</h2>
{t("auth.errors.unknown")}
</>
);
}
}

View File

@ -1,60 +0,0 @@
import clsx from "clsx";
import { Link } from "@remix-run/react";
import navItems from "./nav-items.json";
import { Image } from "../Image";
import { useIsMounted } from "~/hooks/useIsMounted";
import { canPerformAdminActions } from "~/permissions";
import { useUser } from "~/modules/auth";
import { useTranslation } from "~/hooks/useTranslation";
import { navIconUrl } from "~/utils/urls";
export function Menu({
expanded,
closeMenu,
}: {
expanded: boolean;
closeMenu: () => void;
}) {
const user = useUser();
const isMounted = useIsMounted();
const { t } = useTranslation();
// without this menu is initially visible due to SSR and not knowing user screen width on server (probably)
if (!isMounted) return null;
const visibleNavItems = navItems.filter((navItem) => {
if (navItem.name === "admin") {
return canPerformAdminActions(user);
}
return true;
});
return (
<nav className={clsx("layout__menu", { expanded })} aria-hidden={!expanded}>
<div className="layout__menu__links">
{visibleNavItems.map((navItem, i) => (
<Link
key={navItem.name}
className={clsx("layout__menu__link", {
first: i === 0,
last: i + 1 === visibleNavItems.length,
})}
to={navItem.url ?? navItem.name}
onClick={closeMenu}
data-cy={`menu-link-${navItem.name}`}
tabIndex={!expanded ? -1 : undefined}
prefetch={navItem.prefetch ? "render" : undefined}
>
<Image
className="layout__menu__link__icon"
path={navIconUrl(navItem.name)}
alt={navItem.name}
/>
<div>{t(`pages.${navItem.name}` as any)}</div>
</Link>
))}
</div>
</nav>
);
}

View File

@ -0,0 +1,32 @@
import { Link } from "@remix-run/react";
import navItems from "~/components/layout/nav-items.json";
import { useTranslation } from "~/hooks/useTranslation";
import { navIconUrl } from "~/utils/urls";
import { Image } from "../Image";
export function SideNav() {
const { t } = useTranslation(["common"]);
return (
<nav className="layout__side-nav">
{navItems.map((item) => {
return (
<Link
to={item.url}
key={item.name}
prefetch={item.prefetch ? "render" : undefined}
>
<div className="layout__side-nav-image-container">
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt={t(`common:pages.${item.name}` as any)}
/>
</div>
</Link>
);
})}
</nav>
);
}

View File

@ -1,58 +0,0 @@
import { useTranslation } from "~/hooks/useTranslation";
import { Theme, useTheme } from "~/modules/theme";
import { Button } from "../Button";
import { MoonIcon } from "../icons/Moon";
import { SunIcon } from "../icons/Sun";
import { SunAndMoonIcon } from "../icons/SunAndMoon";
import { Popover } from "../Popover";
const ThemeIcons = {
[Theme.LIGHT]: SunIcon,
[Theme.DARK]: MoonIcon,
auto: SunAndMoonIcon,
};
export function ThemeChanger() {
const { userTheme, setUserTheme } = useTheme();
const { t } = useTranslation();
if (!userTheme) {
return null;
}
const SelectedIcon = ThemeIcons[userTheme];
return (
<Popover
buttonChildren={
<SelectedIcon
alt={t("header.theme")}
className="layout__header__button__icon"
/>
}
triggerClassName="layout__header__button"
>
<div className="layout__user-popover">
{(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => {
const Icon = ThemeIcons[theme];
const selected = userTheme === theme;
return (
<Button
variant="minimal"
key={theme}
tiny
icon={<Icon alt="" />}
// TODO: Remove this and find better semantic representation than
// just multiple buttons. Maybe radio group?
aria-current={selected}
className={selected ? undefined : "text-main-forced"}
onClick={() => setUserTheme(theme)}
>
{t(`theme.${theme}`)}
</Button>
);
})}
</div>
</Popover>
);
}

View File

@ -1,19 +1,18 @@
import { Link, useSearchParams } from "@remix-run/react";
import { Link } from "@remix-run/react";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { LOG_IN_URL, LOG_OUT_URL, userPage } from "~/utils/urls";
import { LOG_OUT_URL, userPage } from "~/utils/urls";
import { Avatar } from "../Avatar";
import { Button } from "../Button";
import { Dialog } from "../Dialog";
import { DiscordIcon } from "../icons/Discord";
import { LogInIcon } from "../icons/LogIn";
import { LogOutIcon } from "../icons/LogOut";
import { UserIcon } from "../icons/User";
import { Popover } from "../Popover";
import { LogInButtonContainer } from "./LogInButtonContainer";
export function UserItem() {
const { t } = useTranslation();
const user = useUser();
const [searchParams, setSearchParams] = useSearchParams();
if (user) {
return (
@ -37,11 +36,17 @@ export function UserItem() {
variant="outlined"
icon={<UserIcon />}
>
{t("header.profile")}
{t("pages.myPage")}
</Button>
</Link>
<form method="post" action={LOG_OUT_URL}>
<Button tiny variant="outlined" icon={<LogOutIcon />} type="submit">
<Button
tiny
variant="outlined"
icon={<LogOutIcon />}
type="submit"
className="w-full"
>
{t("header.logout")}
</Button>
</form>
@ -50,50 +55,11 @@ export function UserItem() {
);
}
const authError = searchParams.get("authError");
const closeAuthErrorDialog = () => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.delete("authError");
setSearchParams(newSearchParams);
};
return (
<>
<form action={LOG_IN_URL} method="post">
<button type="submit" className="layout__log-in-button">
<DiscordIcon /> {t("header.login")}
</button>
</form>
{authError != null && (
<Dialog isOpen close={closeAuthErrorDialog}>
<div className="stack md">
<AuthenticationErrorHelp errorCode={authError} />
<Button onClick={closeAuthErrorDialog}>{t("actions.close")}</Button>
</div>
</Dialog>
)}
</>
<LogInButtonContainer>
<button type="submit" className="layout__log-in-button">
<LogInIcon /> {t("header.login")}
</button>
</LogInButtonContainer>
);
}
function AuthenticationErrorHelp({ errorCode }: { errorCode: string }) {
const { t } = useTranslation();
switch (errorCode) {
case "aborted":
return (
<>
<h2 className="text-lg text-center">{t("auth.errors.aborted")}</h2>
{t("auth.errors.discordPermissions")}
</>
);
case "unknown":
default:
return (
<>
<h2 className="text-lg text-center">{t("auth.errors.failed")}</h2>
{t("auth.errors.unknown")}
</>
);
}
}

View File

@ -1,36 +1,32 @@
import { Link, useMatches } from "@remix-run/react";
import { Link, useLocation, useMatches } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "~/hooks/useTranslation";
import type { RootLoaderData } from "~/root";
import { type SendouRouteHandle } from "~/utils/remix";
import { LOGO_PATH, navIconUrl } from "~/utils/urls";
import { Image } from "../Image";
import { ThemeChanger } from "./ThemeChanger";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix";
import { Footer } from "./Footer";
import { HamburgerButton } from "./HamburgerButton";
import { LanguageChanger } from "./LanguageChanger";
import { Menu } from "./Menu";
import navItems from "./nav-items.json";
import { useTranslation } from "~/hooks/useTranslation";
import { Image } from "../Image";
import { SideNav } from "./SideNav";
import { UserItem } from "./UserItem";
import { LanguageChanger } from "./LanguageChanger";
function useActiveNavItem() {
function useBreadcrumbs() {
const { t } = useTranslation();
const matches = useMatches();
return React.useMemo(() => {
let activeItem: { name: string; url: string } | undefined = undefined;
const result: Array<Breadcrumb | Array<Breadcrumb>> = [];
// `.reverse()` is mutating!
for (const match of [...matches].reverse()) {
const handle = match.handle as SendouRouteHandle | undefined;
const resolvedBreadcrumb = handle?.breadcrumb?.({ match, t });
if (handle?.navItemName) {
activeItem = navItems.find(({ name }) => name === handle.navItemName);
break;
if (resolvedBreadcrumb) {
result.push(resolvedBreadcrumb);
}
}
return activeItem;
}, [matches]);
return result.flat();
}, [matches, t]);
}
export const Layout = React.memo(function Layout({
@ -42,45 +38,63 @@ export const Layout = React.memo(function Layout({
patrons?: RootLoaderData["patrons"];
isCatchBoundary?: boolean;
}) {
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = React.useState(false);
const activeNavItem = useActiveNavItem();
const { t } = useTranslation(["common"]);
const location = useLocation();
const breadcrumbs = useBreadcrumbs();
const isFrontPage = location.pathname === "/";
return (
<div className="layout__container">
<header className="layout__header">
<Link to="/" className="layout__logo">
<Image
path={LOGO_PATH}
width={28}
height={28}
alt="sendou.ink logo"
/>
</Link>
<div className="layout__breadcrumb-container">
<Link to="/" className="layout__breadcrumb">
sendou.ink
</Link>
{breadcrumbs.flatMap((breadcrumb) => {
return [
<span
key={`${breadcrumb.href}-sep`}
className="layout__breadcrumb-separator"
>
/
</span>,
<BreadcrumbLink key={breadcrumb.href} data={breadcrumb} />,
];
})}
{isFrontPage ? (
<>
<div className="layout__breadcrumb-separator">-</div>
<div className="layout__breadcrumb">
{t("common:websiteSubtitle")}
</div>
</>
) : null}
</div>
<div className="layout__header__right-container">
{!isCatchBoundary ? <UserItem /> : null}
<LanguageChanger />
<ThemeChanger />
<HamburgerButton
expanded={menuOpen}
onClick={() => setMenuOpen(!menuOpen)}
/>
{!isCatchBoundary ? <UserItem /> : null}
</div>
</header>
<Menu expanded={menuOpen} closeMenu={() => setMenuOpen(false)} />
{activeNavItem && (
<h1 className="layout__page-title-header">
<Image
path={navIconUrl(activeNavItem.name)}
width={28}
height={28}
alt=""
/>
{t(`pages.${activeNavItem.name}` as any)}
</h1>
)}
{!isFrontPage ? <SideNav /> : null}
{children}
<Footer patrons={patrons} />
</div>
);
});
function BreadcrumbLink({ data }: { data: Breadcrumb }) {
if (data.type === "IMAGE") {
return (
<Link to={data.href} className="layout__breadcrumb">
<Image alt="" path={data.imgPath} width={30} height={30} />
</Link>
);
}
return (
<Link to={data.href} className="layout__breadcrumb">
{data.text}
</Link>
);
}

View File

@ -1,27 +1,83 @@
[
{
"name": "admin",
"url": "admin",
"prefetch": false
},
{ "name": "builds", "url": "builds", "prefetch": true },
{ "name": "analyzer", "url": "analyzer", "prefetch": true },
{
"name": "object-damage-calculator",
"url": "object-damage-calculator",
"prefetch": true
},
{
"name": "plans",
"url": "plans",
"prefetch": false
"prefetch": false,
"filters": [
"invert(46%) sepia(98%) saturate(7459%) hue-rotate(327deg) brightness(102%) contrast(94%)",
"invert(68%) sepia(78%) saturate(364%) hue-rotate(74deg) brightness(100%) contrast(100%)"
]
},
{
"name": "maps",
"url": "maps",
"prefetch": false,
"filters": [
"invert(53%) sepia(87%) saturate(411%) hue-rotate(42deg) brightness(95%) contrast(90%)",
"invert(81%) sepia(25%) saturate(0%) hue-rotate(166deg) brightness(89%) contrast(82%)"
]
},
{
"name": "badges",
"url": "badges",
"prefetch": false,
"filters": [
"invert(92%) sepia(88%) saturate(701%) hue-rotate(327deg) brightness(94%) contrast(93%)",
"invert(35%) sepia(84%) saturate(1474%) hue-rotate(329deg) brightness(105%) contrast(101%)"
]
},
{
"name": "calendar",
"url": "calendar",
"prefetch": false,
"filters": [
"invert(71%) sepia(16%) saturate(7491%) hue-rotate(76deg) brightness(107%) contrast(100%)",
"invert(26%) sepia(59%) saturate(5210%) hue-rotate(323deg) brightness(98%) contrast(101%)"
]
},
{ "name": "calendar", "url": "calendar", "prefetch": false },
{ "name": "maps", "url": "maps", "prefetch": false },
{ "name": "badges", "url": "badges", "prefetch": false },
{
"name": "plus",
"url": "plus/suggestions",
"prefetch": false
"prefetch": false,
"filters": [
"invert(68%) sepia(69%) saturate(1761%) hue-rotate(145deg) brightness(101%) contrast(108%)",
"invert(22%) sepia(100%) saturate(2645%) hue-rotate(279deg) brightness(98%) contrast(115%)"
]
},
{
"name": "articles",
"url": "a",
"prefetch": true,
"filters": [
"invert(30%) sepia(73%) saturate(4089%) hue-rotate(326deg) brightness(96%) contrast(104%)",
"invert(55%) sepia(20%) saturate(738%) hue-rotate(224deg) brightness(86%) contrast(88%)"
]
},
{
"name": "object-damage-calculator",
"url": "object-damage-calculator",
"prefetch": true,
"filters": [
"invert(19%) sepia(97%) saturate(6605%) hue-rotate(326deg) brightness(103%) contrast(105%)",
"invert(20%) sepia(99%) saturate(4341%) hue-rotate(220deg) brightness(102%) contrast(104%)"
]
},
{
"name": "analyzer",
"url": "analyzer",
"prefetch": true,
"filters": [
"invert(27%) sepia(69%) saturate(5653%) hue-rotate(220deg) brightness(96%) contrast(85%)",
"invert(23%) sepia(83%) saturate(2378%) hue-rotate(334deg) brightness(87%) contrast(102%)"
]
},
{
"name": "builds",
"url": "builds",
"prefetch": true,
"filters": [
"invert(16%) sepia(80%) saturate(5634%) hue-rotate(272deg) brightness(91%) contrast(105%)",
"invert(71%) sepia(42%) saturate(4219%) hue-rotate(150deg) brightness(99%) contrast(86%)"
]
}
]

View File

@ -1,3 +0,0 @@
Implements dark mode for a Remix app.
Based on https://github.com/remix-run/remix/blob/main/examples/dark-mode

View File

@ -1,32 +0,0 @@
import type { ActionFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { getThemeSession } from "./session.server";
import { isTheme } from "./provider";
export const action: ActionFunction = async ({ request }) => {
const themeSession = await getThemeSession(request);
const requestText = await request.text();
const form = new URLSearchParams(requestText);
const theme = form.get("theme");
if (theme === "auto") {
return json(
{ success: true },
{ headers: { "Set-Cookie": await themeSession.destroy() } }
);
}
if (!isTheme(theme)) {
return json({
success: false,
message: `theme value of ${theme ?? "null"} is not a valid theme`,
});
}
themeSession.setTheme(theme);
return json(
{ success: true },
{ headers: { "Set-Cookie": await themeSession.commit() } }
);
};

View File

@ -1,2 +0,0 @@
export { Theme, ThemeHead, ThemeProvider, useTheme } from "./provider";
export { action } from "./action.server";

View File

@ -1,193 +0,0 @@
import { useFetcher } from "@remix-run/react";
import { type ReactNode, useCallback } from "react";
import { createContext, useContext, useEffect, useState } from "react";
enum Theme {
DARK = "dark",
LIGHT = "light",
}
const themes: Array<Theme> = Object.values(Theme);
type ThemeContextType = {
/** The CSS class to attach to the `html` tag */
htmlThemeClass: Theme | "";
/** The color scheme to be defined in the meta tag */
metaColorScheme: "light dark" | "dark light";
/**
* The Theme setting of the user, as displayed in the theme switcher.
* `null` means there is no theme switcher (static theme on error pages).
*/
userTheme: Theme | "auto" | null;
/** Persists a new `userTheme` setting */
setUserTheme: (newTheme: Theme | "auto") => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const prefersLightMQ = "(prefers-color-scheme: light)";
const getPreferredTheme = () =>
window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK;
type ThemeProviderProps = {
children: ReactNode;
specifiedTheme: Theme | null;
themeSource: "user-preference" | "static";
};
function ThemeProvider({
children,
specifiedTheme,
themeSource,
}: ThemeProviderProps) {
const [[theme, isAutoDetected], setThemeState] = useState<
[Theme, false] | [Theme | null, true]
>(() => {
if (themeSource === "static") {
return [specifiedTheme ?? Theme.DARK, false];
}
if (specifiedTheme) {
return [specifiedTheme, false];
}
/*
If we don't know a preferred user theme, we have to auto-detect it.
Since the server has no way of doing auto-detection, it returns null,
leading to the `html` class and `color-scheme` values being set to a
default.
Then, on the client, the `clientThemeCode` will run, correcting those
defaults with the determined correct value.
Which means, when we later render this component again, hydration will
succeed. Because the output of `getPreferredTheme()` is (very likely) the
same that the `clientThemeCode` determined and added to the html element
shortly before.
*/
return [typeof document === "undefined" ? null : getPreferredTheme(), true];
});
const persistThemeFetcher = useFetcher();
const persistTheme = persistThemeFetcher.submit;
const setUserTheme = useCallback(
(newTheme: Theme | "auto") => {
setThemeState(
newTheme === "auto" ? [getPreferredTheme(), true] : [newTheme, false]
);
persistTheme(
{ theme: newTheme },
{
action: "theme",
method: "post",
}
);
},
[setThemeState, persistTheme]
);
useEffect(() => {
if (!isAutoDetected) {
return;
}
const mediaQuery = window.matchMedia(prefersLightMQ);
const handleChange = () => {
setThemeState([mediaQuery.matches ? Theme.LIGHT : Theme.DARK, true]);
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [isAutoDetected]);
return (
<ThemeContext.Provider
value={{
// Gets corrected by clientThemeCode if set to "" during SSR
htmlThemeClass: theme ?? "",
// Gets corrected by clientThemeCode if set to wrong value during SSR
metaColorScheme: theme === "light" ? "light dark" : "dark light",
userTheme:
themeSource === "static" ? null : isAutoDetected ? "auto" : theme!,
setUserTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}
// this is how I make certain we avoid a flash of the wrong theme. If you select
// a theme, then I'll know what you want in the future and you'll not see this
// script anymore.
const clientThemeCode = `
;(() => {
const theme = window.matchMedia(${JSON.stringify(prefersLightMQ)}).matches
? 'light'
: 'dark';
const cl = document.documentElement.classList;
const themeAlreadyApplied = cl.contains('light') || cl.contains('dark');
if (themeAlreadyApplied) {
console.warn(
"Script is running but theme is already applied",
);
} else {
cl.add(theme);
}
const meta = document.querySelector('meta[name=color-scheme]');
if (meta) {
if (theme === 'dark') {
meta.content = 'dark light';
} else if (theme === 'light') {
meta.content = 'light dark';
}
} else {
console.warn(
"No meta tag",
);
}
})();
`;
function ThemeHead() {
const { userTheme, metaColorScheme } = useTheme();
const [initialUserTheme] = useState(userTheme);
return (
<>
{/*
On the server, "theme" might be `null`, so clientThemeCode ensures that
this is correct before hydration.
*/}
<meta name="color-scheme" content={metaColorScheme} />
{/*
If we know what the theme is from user preference, then we don't need
to do fancy tricks prior to hydration to make things match.
*/}
{initialUserTheme === "auto" && (
<script
// NOTE: we cannot use type="module" because that automatically makes
// the script "defer". That doesn't work for us because we need
// this script to run synchronously before the rest of the document
// is finished loading.
dangerouslySetInnerHTML={{ __html: clientThemeCode }}
/>
)}
</>
);
}
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
function isTheme(value: unknown): value is Theme {
return typeof value === "string" && themes.includes(value as Theme);
}
export { isTheme, Theme, ThemeHead, ThemeProvider, useTheme };

View File

@ -1,39 +0,0 @@
import { createCookieSessionStorage } from "@remix-run/node";
import { isTheme } from "./provider";
import type { Theme } from "./provider";
import invariant from "tiny-invariant";
const TEN_YEARS_IN_SECONDS = 315_360_000;
if (process.env.NODE_ENV === "production") {
invariant(process.env["SESSION_SECRET"], "SESSION_SECRET is required");
}
const sessionSecret = process.env["SESSION_SECRET"] ?? "secret";
const themeStorage = createCookieSessionStorage({
cookie: {
name: "theme",
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
httpOnly: true,
maxAge: TEN_YEARS_IN_SECONDS,
},
});
async function getThemeSession(request: Request) {
const session = await themeStorage.getSession(request.headers.get("Cookie"));
return {
getTheme: () => {
const themeValue = session.get("theme");
return isTheme(themeValue) ? themeValue : null;
},
setTheme: (theme: Theme) => session.set("theme", theme),
commit: () => themeStorage.commitSession(session),
destroy: () => themeStorage.destroySession(session, { maxAge: 0 }),
};
}
export { getThemeSession };

View File

@ -30,13 +30,10 @@ import { DEFAULT_LANGUAGE, i18nCookie, i18next } from "./modules/i18n";
import { useChangeLanguage } from "remix-i18next";
import { type CustomTypeOptions } from "react-i18next";
import { useTranslation } from "~/hooks/useTranslation";
import { Theme, ThemeHead, useTheme, ThemeProvider } from "./modules/theme";
import { getThemeSession } from "./modules/theme/session.server";
import { COMMON_PREVIEW_IMAGE } from "./utils/urls";
import { ConditionalScrollRestoration } from "./components/ConditionalScrollRestoration";
import { type SendouRouteHandle } from "~/utils/remix";
import generalI18next from "i18next";
import { isTheme } from "./modules/theme/provider";
export const unstable_shouldReload: ShouldReloadFunction = ({ url }) => {
// reload on language change so the selected language gets set into the cookie
@ -67,7 +64,6 @@ export const meta: MetaFunction = () => ({
export interface RootLoaderData {
locale: string;
theme: string | null;
patrons: FindAllPatrons;
user?: Pick<
UserWithPlusTier,
@ -83,12 +79,10 @@ export interface RootLoaderData {
export const loader: LoaderFunction = async ({ request }) => {
const user = await getUser(request);
const locale = await i18next.getLocale(request);
const themeSession = await getThemeSession(request);
return json<RootLoaderData>(
{
locale,
theme: themeSession.getTheme(),
patrons: db.users.findAllPatrons(),
user: user
? {
@ -114,11 +108,12 @@ export const handle: SendouRouteHandle = {
function Document({
children,
data,
isCatchBoundary = false,
}: {
children: React.ReactNode;
data?: RootLoaderData;
isCatchBoundary?: boolean;
}) {
const { htmlThemeClass } = useTheme();
const { i18n } = useTranslation();
const locale = data?.locale ?? DEFAULT_LANGUAGE;
@ -126,15 +121,15 @@ function Document({
usePreloadTranslation();
return (
<html lang={locale} dir={i18n.dir()} className={htmlThemeClass}>
<html lang={locale} dir={i18n.dir()}>
<head>
<Meta />
<Links />
<ThemeHead />
<Fonts />
</head>
<body>
<React.StrictMode>
<Layout patrons={data?.patrons} isCatchBoundary={!data}>
<Layout patrons={data?.patrons} isCatchBoundary={isCatchBoundary}>
{children}
</Layout>
</React.StrictMode>
@ -146,6 +141,23 @@ function Document({
);
}
function Fonts() {
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="true"
/>
<link
href="https://fonts.googleapis.com/css2?family=Lexend:wght@400;600;700&display=swap"
rel="stylesheet"
/>
</>
);
}
// TODO: this should be an array if we can figure out how to make Typescript
// enforce that it has every member of keyof CustomTypeOptions["resources"] without duplicating the type manually
export const namespaceJsonsToPreloadObj: Record<
@ -159,7 +171,6 @@ export const namespaceJsonsToPreloadObj: Record<
calendar: true,
contributions: true,
faq: true,
front: true,
"game-misc": true,
gear: true,
user: true,
@ -180,24 +191,17 @@ export default function App() {
const data = useLoaderData<RootLoaderData>();
return (
<ThemeProvider
specifiedTheme={isTheme(data.theme) ? data.theme : null}
themeSource="user-preference"
>
<Document data={data}>
<Outlet />
</Document>
</ThemeProvider>
<Document data={data}>
<Outlet />
</Document>
);
}
export function CatchBoundary() {
return (
<ThemeProvider themeSource="static" specifiedTheme={Theme.DARK}>
<Document>
<Catcher />
</Document>
</ThemeProvider>
<Document isCatchBoundary>
<Catcher />
</Document>
);
}
@ -205,10 +209,8 @@ export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => {
console.error(error);
return (
<ThemeProvider themeSource="static" specifiedTheme={Theme.DARK}>
<Document>
<Catcher />
</Document>
</ThemeProvider>
<Document>
<Catcher />
</Document>
);
};

View File

@ -11,9 +11,33 @@ import * as React from "react";
import { articleBySlug } from "~/modules/articles";
import invariant from "tiny-invariant";
import { makeTitle } from "~/utils/strings";
import { articlePreviewUrl } from "~/utils/urls";
import {
articlePage,
articlePreviewUrl,
ARTICLES_MAIN_PAGE,
navIconUrl,
} from "~/utils/urls";
import type { SendouRouteHandle } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix";
export const handle: SendouRouteHandle = {
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader>;
return [
{
imgPath: navIconUrl("articles"),
href: ARTICLES_MAIN_PAGE,
type: "IMAGE",
},
{
text: data.title,
href: articlePage(data.slug),
type: "TEXT",
},
];
},
};
export const meta: MetaFunction = (args) => {
invariant(args.params["slug"]);
const data = args.data as SerializeFrom<typeof loader> | null;
@ -37,7 +61,9 @@ export const meta: MetaFunction = (args) => {
export const loader = ({ params }: LoaderArgs) => {
invariant(params["slug"]);
return json(notFoundIfFalsy(articleBySlug(params["slug"])));
const article = notFoundIfFalsy(articleBySlug(params["slug"]));
return json({ ...article, slug: params["slug"] });
};
export default function ArticlePage() {

View File

@ -1,12 +1,12 @@
import { useLoaderData } from "@remix-run/react";
import { Main } from "~/components/Main";
import type { LinksFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { mostRecentArticles } from "~/modules/articles";
import styles from "~/styles/front.css";
import { ArticlesPeek } from ".";
import { useTranslation } from "~/hooks/useTranslation";
import type { SendouRouteHandle } from "~/utils/remix";
import { articlePage, ARTICLES_MAIN_PAGE, navIconUrl } from "~/utils/urls";
import { Link, useLoaderData } from "@remix-run/react";
const MAX_ARTICLES_COUNT = 100;
@ -15,24 +15,41 @@ export const links: LinksFunction = () => {
};
export const handle: SendouRouteHandle = {
i18n: ["front"],
breadcrumb: () => ({
imgPath: navIconUrl("articles"),
href: ARTICLES_MAIN_PAGE,
type: "IMAGE",
}),
};
export const loader = async () => {
return json({
recentArticles: await mostRecentArticles(MAX_ARTICLES_COUNT),
articles: await mostRecentArticles(MAX_ARTICLES_COUNT),
});
};
export default function ArticlesMainPage() {
const { t } = useTranslation("common");
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const articles = data.recentArticles;
return (
<Main className="stack lg">
<h1>{t("pages.articles")}</h1>
<ArticlesPeek articles={articles} />
<ul className="articles-list">
{data.articles.map((article) => (
<li key={article.title}>
<Link
to={articlePage(article.slug)}
className="articles-list__title"
>
{article.title}
</Link>
<div className="text-xs text-lighter">
{t("common:articles.by", { author: article.author })} {" "}
<time>{article.dateString}</time>
</div>
</li>
))}
</ul>
</Main>
);
}

View File

@ -42,6 +42,7 @@ import { damageTypeTranslationString } from "~/utils/i18next";
import { type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import {
ANALYZER_URL,
navIconUrl,
objectDamageCalculatorPage,
specialWeaponImageUrl,
@ -64,7 +65,11 @@ export const links: LinksFunction = () => {
export const handle: SendouRouteHandle = {
i18n: ["weapons", "analyzer"],
navItemName: "analyzer",
breadcrumb: () => ({
imgPath: navIconUrl("analyzer"),
href: ANALYZER_URL,
type: "IMAGE",
}),
};
// Resolves this Github issue: https://github.com/Sendouc/sendou.ink/issues/1053

View File

@ -6,7 +6,12 @@ import { Main } from "~/components/Main";
import { db } from "~/db";
import type { FindAll } from "~/db/models/badges/queries.server";
import styles from "~/styles/badges.css";
import { BORZOIC_TWITTER, FAQ_PAGE } from "~/utils/urls";
import {
BADGES_PAGE,
BORZOIC_TWITTER,
FAQ_PAGE,
navIconUrl,
} from "~/utils/urls";
import { Trans } from "react-i18next";
import { useTranslation } from "~/hooks/useTranslation";
import { useAnimateListEntry } from "~/hooks/useAnimateListEntry";
@ -22,7 +27,11 @@ export interface BadgesLoaderData {
export const handle: SendouRouteHandle = {
i18n: "badges",
navItemName: "badges",
breadcrumb: () => ({
imgPath: navIconUrl("badges"),
href: BADGES_PAGE,
type: "IMAGE",
}),
};
export const loader: LoaderFunction = () => {

View File

@ -1,39 +0,0 @@
import { type LinksFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react";
import { useTranslation } from "~/hooks/useTranslation";
import { LinkButton } from "~/components/Button";
import { Main } from "~/components/Main";
import { useUser } from "~/modules/auth";
import { type SendouRouteHandle } from "~/utils/remix";
import styles from "~/styles/builds.css";
import { userNewBuildPage } from "~/utils/urls";
import { Breadcrumbs } from "~/components/Breadcrumbs";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "gear"],
breadcrumb: ({ t }) => t("pages.builds"),
navItemName: "builds",
};
export default function BuildsLayoutPage() {
const user = useUser();
const { t } = useTranslation(["weapons", "common", "builds"]);
return (
<Main className="stack lg">
<div className="builds__top-container">
<Breadcrumbs />
{user && (
<LinkButton to={userNewBuildPage(user)} tiny>
{t("builds:addBuild")}
</LinkButton>
)}
</div>
<Outlet />
</Main>
);
}

View File

@ -14,6 +14,14 @@ import { weaponIdIsNotAlt } from "~/modules/in-game-lists";
import { type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import {
BUILDS_PAGE,
mySlugify,
navIconUrl,
outlinedMainWeaponImageUrl,
weaponBuildPage,
} from "~/utils/urls";
import { Main } from "~/components/Main";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
@ -25,6 +33,26 @@ export const meta: MetaFunction = (args) => {
};
};
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "gear"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader>;
return [
{
imgPath: navIconUrl("builds"),
href: BUILDS_PAGE,
type: "IMAGE",
},
{
imgPath: outlinedMainWeaponImageUrl(data.weaponId),
href: weaponBuildPage(data.slug),
type: "IMAGE",
},
];
},
};
export const loader = async ({ request, params }: LoaderArgs) => {
const t = await i18next.getFixedT(request, ["weapons", "common"], {
lng: "en",
@ -43,6 +71,8 @@ export const loader = async ({ request, params }: LoaderArgs) => {
const weaponName = t(`weapons:MAIN_${weaponId}`);
const slug = mySlugify(t(`weapons:MAIN_${weaponId}`, { lng: "en" }));
return {
weaponId,
weaponName,
@ -52,23 +82,16 @@ export const loader = async ({ request, params }: LoaderArgs) => {
limit,
}),
limit,
slug,
};
};
export const handle: SendouRouteHandle = {
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader> | null;
return data ? data.weaponName : "Unknown";
},
};
export default function WeaponsBuildsPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["common"]);
return (
<div className="stack lg">
<Main className="stack lg">
<div className="builds-container">
{data.builds.map((build) => {
return (
@ -93,6 +116,6 @@ export default function WeaponsBuildsPage() {
{t("common:actions.loadMore")}
</LinkButton>
)}
</div>
</Main>
);
}

View File

@ -3,11 +3,29 @@ import { useTranslation } from "~/hooks/useTranslation";
import { Image } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { weaponCategories, weaponIdIsNotAlt } from "~/modules/in-game-lists";
import { mainWeaponImageUrl, mySlugify, weaponCategoryUrl } from "~/utils/urls";
import {
BUILDS_PAGE,
mainWeaponImageUrl,
mySlugify,
navIconUrl,
weaponCategoryUrl,
} from "~/utils/urls";
import { type SendouRouteHandle } from "~/utils/remix";
import styles from "~/styles/builds.css";
import type { LinksFunction } from "@remix-run/node";
import { Main } from "~/components/Main";
export const handle: SendouRouteHandle = {
i18n: "weapons",
breadcrumb: () => ({
imgPath: navIconUrl("builds"),
href: BUILDS_PAGE,
type: "IMAGE",
}),
};
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export default function BuildsPage() {
@ -18,7 +36,7 @@ export default function BuildsPage() {
};
return (
<div className="stack md">
<Main className="stack md">
{weaponCategories.map((category) => (
<div key={category.name} className="builds__category">
<div className="builds__category__header">
@ -55,6 +73,6 @@ export default function BuildsPage() {
</div>
</div>
))}
</div>
</Main>
);
}

View File

@ -38,6 +38,7 @@ import {
import { discordFullName, makeTitle } from "~/utils/strings";
import {
calendarEditPage,
calendarEventPage,
calendarReportWinnersPage,
CALENDAR_PAGE,
navIconUrl,
@ -92,6 +93,21 @@ export const meta: MetaFunction = (args) => {
export const handle: SendouRouteHandle = {
i18n: ["calendar", "game-misc"],
breadcrumb: ({ match }) => {
const data = match.data as SerializeFrom<typeof loader>;
return [
{
imgPath: navIconUrl("calendar"),
href: CALENDAR_PAGE,
type: "IMAGE",
},
{
text: data.event.name,
href: calendarEventPage(data.event.eventId),
type: "TEXT",
},
];
},
};
export const loader = async ({ params, request }: LoaderArgs) => {

View File

@ -22,7 +22,12 @@ import {
} from "~/utils/dates";
import { discordFullName, makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { calendarReportWinnersPage, resolveBaseUrl } from "~/utils/urls";
import {
calendarReportWinnersPage,
CALENDAR_PAGE,
navIconUrl,
resolveBaseUrl,
} from "~/utils/urls";
import { actualNumber } from "~/utils/zod";
import { Tags } from "./components/Tags";
import { type SendouRouteHandle } from "~/utils/remix";
@ -48,7 +53,11 @@ export const meta: MetaFunction = (args) => {
export const handle: SendouRouteHandle = {
i18n: "calendar",
navItemName: "calendar",
breadcrumb: () => ({
imgPath: navIconUrl("calendar"),
href: CALENDAR_PAGE,
type: "IMAGE",
}),
};
const loaderSearchParamsSchema = z.object({

View File

@ -1,322 +1,155 @@
import { json, type LinksFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type React from "react";
import { useTranslation } from "~/hooks/useTranslation";
import { Link } from "react-router-dom";
import { BuildCard } from "~/components/BuildCard";
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { db } from "~/db";
import { useIsMounted } from "~/hooks/useIsMounted";
import { mostRecentArticles } from "~/modules/articles";
import styles from "~/styles/front.css";
import { databaseTimestampToDate } from "~/utils/dates";
import { discordFullName } from "~/utils/strings";
import navItems from "~/components/layout/nav-items.json";
import { Image } from "~/components/Image";
import {
analyzerPage,
articlePage,
ARTICLES_MAIN_PAGE,
BADGES_PAGE,
BUILDS_PAGE,
calendarEventPage,
CALENDAR_PAGE,
mapsPage,
FRONT_BOY_BG_PATH,
FRONT_BOY_PATH,
FRONT_GIRL_BG_PATH,
FRONT_GIRL_PATH,
LOG_OUT_URL,
navIconUrl,
objectDamageCalculatorPage,
PLANNER_URL,
plusSuggestionPage,
userPage,
} from "~/utils/urls";
import { Tags } from "./calendar/components/Tags";
import { type SendouRouteHandle } from "~/utils/remix";
const RECENT_ARTICLES_TO_SHOW = 3;
import { useTranslation } from "~/hooks/useTranslation";
import type { LinksFunction } from "@remix-run/node";
import styles from "~/styles/front.css";
import { Link } from "@remix-run/react";
import { GlobeIcon } from "~/components/icons/Globe";
import { LanguageChanger } from "~/components/layout/LanguageChanger";
import { Avatar } from "~/components/Avatar";
import { useUser } from "~/modules/auth";
import { languages } from "~/modules/i18n";
import { Button } from "~/components/Button";
import { LogOutIcon } from "~/components/icons/LogOut";
import { LogInButtonContainer } from "~/components/layout/LogInButtonContainer";
import { LogInIcon } from "~/components/icons/LogIn";
import * as React from "react";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "front", "gear"],
};
export default function FrontPage() {
const [filters, setFilters] = React.useState<[string, string]>(
navItems[0]?.filters as [string, string]
);
const { t, i18n } = useTranslation(["common"]);
const user = useUser();
export const loader = async () => {
return json({
upcomingEvents: db.calendarEvents.upcomingEvents(),
recentBuilds: db.builds.recentBuilds(),
recentWinners: db.calendarEvents.recentWinners(),
recentArticles: await mostRecentArticles(RECENT_ARTICLES_TO_SHOW),
});
};
export default function Index() {
const { t } = useTranslation(["common", "front"]);
const data = useLoaderData<typeof loader>();
const articles = data.recentArticles;
const selectedLanguage = languages.find(
(lang) => i18n.language === lang.code
);
return (
<Main className="stack lg">
<Header />
<div className="stack md">
<BuildsPeek />
<GoToPageBanner to={BUILDS_PAGE} navItem="builds">
{t("front:buildsGoTo")}
</GoToPageBanner>
</div>
<div className="stack md">
<CalendarPeek />
<GoToPageBanner to={CALENDAR_PAGE} navItem="calendar">
{t("front:calendarGoTo")}
</GoToPageBanner>
</div>
<div className="stack md">
<ArticlesPeek articles={articles} />
<GoToPageBanner to={ARTICLES_MAIN_PAGE}>
{t("front:articlesGoTo")}
</GoToPageBanner>
</div>
<div className="stack md">
<h2 className="front__more-features">{t("front:moreFeatures")}</h2>
<div className="front__feature-cards">
<FeatureCard
navItem="analyzer"
title={t("common:pages.analyzer")}
description={t("front:analyzer.description")}
to={analyzerPage()}
/>
<FeatureCard
navItem="object-damage-calculator"
title={t("common:pages.object-damage-calculator")}
description={t("front:object-damage-calculator.description")}
to={objectDamageCalculatorPage()}
/>
<FeatureCard
navItem="plus"
title={t("common:pages.plus")}
description={t("front:plus.description")}
to={plusSuggestionPage()}
/>
<FeatureCard
navItem="badges"
title={t("common:pages.badges")}
description={t("front:badges.description")}
to={BADGES_PAGE}
/>
<FeatureCard
navItem="maps"
title={t("common:pages.maps")}
description={t("front:maps.description")}
to={mapsPage()}
/>
<FeatureCard
navItem="plans"
title={t("common:pages.plans")}
description={t("front:plans.description")}
to={PLANNER_URL}
/>
<div className="front__nav-items-container">
<div className="front__nav-item round">
<LanguageChanger plain>
<div className="front__nav-image-container round">
<GlobeIcon size={28} alt={t("common:header.language")} />
</div>
</LanguageChanger>
{selectedLanguage?.name ?? ""}
</div>
<div className="front__nav-item round" />
{user ? (
<Link to={userPage(user)} className="front__nav-item round">
<Avatar
user={user}
alt={t("common:header.loggedInAs", {
userName: `${user.discordName}`,
})}
className="front__avatar"
size="sm"
/>
{t("common:pages.myPage")}
</Link>
) : (
<div className="front__nav-item round">
<LogInButtonContainer>
<button className="front__log-in-button">
<LogInIcon size={28} />
</button>
</LogInButtonContainer>
{t("common:header.login")}
</div>
)}
{navItems.map((item) => (
<Link
to={item.url}
className="front__nav-item"
key={item.name}
prefetch={item.prefetch ? "render" : undefined}
onMouseEnter={() => setFilters(item.filters as [string, string])}
>
<div className="front__nav-image-container">
<Image
path={navIconUrl(item.name)}
height={48}
width={48}
alt=""
/>
</div>
<div>{t(`common:pages.${item.name}` as any)}</div>
</Link>
))}
</div>
{user ? (
<div className="front__log-out-container">
<form method="post" action={LOG_OUT_URL}>
<Button
tiny
variant="outlined"
icon={<LogOutIcon />}
type="submit"
className="w-full"
>
{t("common:header.logout")}
</Button>
</form>
</div>
) : null}
<Drawings filters={filters} />
</Main>
);
}
function Header() {
const { t } = useTranslation("front");
return (
<div className="front__logo-container">
<h1>sendou.ink</h1>
<h2>{t("websiteSubtitle")}</h2>
</div>
);
}
function GoToPageBanner({
children,
to,
navItem,
function Drawings({
filters,
}: {
children: React.ReactNode;
to: string;
navItem?: string;
filters: [boyFilter: string, girlFilter: string];
}) {
return (
<Link to={to} className="front__go-to-page-banner">
<div className="front__go-to-page-banner__nav-img-container">
{navItem && (
<Image
path={navIconUrl(navItem)}
alt={navItem}
width={32}
height={32}
/>
)}
</div>
{children}
<ArrowRightIcon className="front__go-to-page-banner__arrow-right" />
</Link>
);
}
function BuildsPeek() {
const data = useLoaderData<typeof loader>();
return (
<div className="front__builds-wrapper">
<div className="builds-container front__builds-container">
{data.recentBuilds.map((build) => (
<BuildCard
key={build.id}
build={build}
owner={build}
canEdit={false}
/>
))}
</div>
</div>
);
}
function CalendarPeek() {
const data = useLoaderData<typeof loader>();
const { t, i18n } = useTranslation("front");
return (
<div className="front__calendar-peek-container">
<div className="stack sm">
<h2 className="front__calendar-header">{t("recentWinners")}</h2>
{data.recentWinners.map((result) => (
<Event
key={result.eventId}
eventId={result.eventId}
eventName={result.eventName}
startTimeString={databaseTimestampToDate(
result.startTime
).toLocaleDateString(i18n.language, {
day: "numeric",
month: "long",
})}
>
<ul className="front__event-winners">
{result.players.map((player) => (
<li
key={typeof player === "string" ? player : player.id}
className="flex items-center"
>
{typeof player === "string" ? (
player
) : (
<Link
to={userPage(player)}
className="stack horizontal xs items-center"
>
{discordFullName(player)}
</Link>
)}
</li>
))}
</ul>
</Event>
))}
</div>
<div className="stack sm">
<h2 className="front__calendar-header">{t("upcomingEvents")}</h2>
{data.upcomingEvents.map((event) => (
<Event
key={event.eventId}
eventId={event.eventId}
eventName={event.eventName}
startTimeString={databaseTimestampToDate(
event.startTime
).toLocaleString(i18n.language, {
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
})}
>
<Tags tags={event.tags} badges={event.badgePrizes} />
</Event>
))}
</div>
</div>
);
}
function Event({
eventId,
eventName,
startTimeString,
children,
}: {
eventId: number;
eventName: string;
startTimeString: string;
children: React.ReactNode;
}) {
const isMounted = useIsMounted();
return (
<div className="front__event">
<Link to={calendarEventPage(eventId)} className="front__event-name">
{eventName}
</Link>
{isMounted && <div className="front__event-time">{startTimeString}</div>}
<div className="front__event-content-below">{children}</div>
</div>
);
}
export function ArticlesPeek({
articles,
}: {
articles: {
title: string;
author: string;
slug: string;
dateString: string;
}[];
}) {
const { t } = useTranslation("front");
return (
<ul className="front__articles">
{articles.map((article) => (
<li key={article.title}>
<Link to={articlePage(article.slug)}>{article.title}</Link>
<div className="text-xs text-lighter">
{t("articleBy", { author: article.author })} {" "}
<time>{article.dateString}</time>
</div>
</li>
))}
</ul>
);
}
function FeatureCard({
navItem,
title,
description,
to,
}: {
navItem: string;
title: string;
description: string;
to: string;
}) {
return (
<Link to={to} className="front__feature-card">
<div className="front__drawings">
<Image
path={navIconUrl(navItem)}
alt={navItem}
width={48}
height={48}
className="front__feature-card__nav-icon"
path={FRONT_BOY_PATH}
className="front__drawing-img"
containerClassName="front__drawings__boy"
alt=""
/>
<h3 className="front__feature-card__title">{title}</h3>
<div className="front__feature-card__description">{description}</div>
</Link>
<Image
path={FRONT_BOY_BG_PATH}
className="front__drawing-img"
containerClassName="front__drawings__boy bg"
style={{ filter: filters[0] }}
alt=""
/>
<Image
path={FRONT_GIRL_PATH}
className="front__drawing-img"
containerClassName="front__drawings__girl"
alt=""
/>
<Image
path={FRONT_GIRL_BG_PATH}
className="front__drawing-img"
containerClassName="front__drawings__girl bg"
style={{ filter: filters[1] }}
alt=""
/>
</div>
);
}

View File

@ -26,7 +26,12 @@ import {
import { MapPool } from "~/modules/map-pool-serializer";
import styles from "~/styles/maps.css";
import { makeTitle } from "~/utils/strings";
import { calendarEventPage, ipLabsMaps } from "~/utils/urls";
import {
calendarEventPage,
ipLabsMaps,
MAPS_URL,
navIconUrl,
} from "~/utils/urls";
import { type SendouRouteHandle } from "~/utils/remix";
import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector";
import { EditIcon } from "~/components/icons/Edit";
@ -58,7 +63,11 @@ export const meta: MetaFunction = (args) => {
export const handle: SendouRouteHandle = {
i18n: "game-misc",
navItemName: "maps",
breadcrumb: () => ({
imgPath: navIconUrl("maps"),
href: MAPS_URL,
type: "IMAGE",
}),
};
export const loader = async ({ request }: LoaderArgs) => {

View File

@ -16,6 +16,8 @@ import {
import {
mainWeaponImageUrl,
modeImageUrl,
navIconUrl,
OBJECT_DAMAGE_CALCULATOR_URL,
specialWeaponImageUrl,
subWeaponImageUrl,
} from "~/utils/urls";
@ -30,16 +32,23 @@ import { Label } from "~/components/Label";
import { Ability } from "~/components/Ability";
import { damageTypeTranslationString } from "~/utils/i18next";
import { useSetTitle } from "~/hooks/useSetTitle";
import type { ShouldReloadFunction } from "@remix-run/react";
export const CURRENT_PATCH = "2.0";
export const unstable_shouldReload: ShouldReloadFunction = () => false;
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export const handle: SendouRouteHandle = {
i18n: ["weapons", "analyzer"],
navItemName: "object-damage-calculator",
breadcrumb: () => ({
imgPath: navIconUrl("object-damage-calculator"),
href: OBJECT_DAMAGE_CALCULATOR_URL,
type: "IMAGE",
}),
};
export default function ObjectDamagePage() {
@ -174,20 +183,16 @@ const damageReceiverImages: Record<DamageReceiver, string> = {
BulletUmbrellaCanopyCompact: mainWeaponImageUrl(6020),
};
const damageReceiverAp: Record<DamageReceiver, JSX.Element> = {
Bomb_TorpedoBullet: <div />,
Chariot: <div />,
Gachihoko_Barrier: <div />,
GreatBarrier_Barrier: <Ability ability="SPU" size="TINY" />,
GreatBarrier_WeakPoint: <Ability ability="SPU" size="TINY" />,
NiceBall_Armor: <div />,
ShockSonar: <div />,
Wsb_Flag: <div />,
Wsb_Shield: <Ability ability="BRU" size="TINY" />,
Wsb_Sprinkler: <div />,
BulletUmbrellaCanopyNormal: <div />,
BulletUmbrellaCanopyWide: <div />,
BulletUmbrellaCanopyCompact: <div />,
const damageReceiverAp: Partial<Record<DamageReceiver, JSX.Element>> = {
GreatBarrier_Barrier: (
<Ability ability="SPU" size="TINY" className="object-damage__ability" />
),
GreatBarrier_WeakPoint: (
<Ability ability="SPU" size="TINY" className="object-damage__ability" />
),
Wsb_Shield: (
<Ability ability="BRU" size="TINY" className="object-damage__ability" />
),
};
function DamageReceiversGrid({
@ -255,6 +260,7 @@ function DamageReceiversGrid({
</div>
</Label>
<Image
className="object-damage__receiver-image"
key={i}
alt=""
path={damageReceiverImages[damageToReceiver.receiver]}

View File

@ -3,9 +3,15 @@ import type { LinksFunction } from "@remix-run/node";
import styles from "~/styles/plans.css";
import type { SendouRouteHandle } from "~/utils/remix";
import { useIsMounted } from "~/hooks/useIsMounted";
import { navIconUrl, PLANNER_URL } from "~/utils/urls";
export const handle: SendouRouteHandle = {
i18n: ["weapons"],
breadcrumb: () => ({
imgPath: navIconUrl("plans"),
href: PLANNER_URL,
type: "IMAGE",
}),
};
export const links: LinksFunction = () => {

View File

@ -4,6 +4,7 @@ import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
import styles from "~/styles/plus.css";
import { type SendouRouteHandle } from "~/utils/remix";
import { navIconUrl, plusSuggestionPage } from "~/utils/urls";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@ -11,19 +12,22 @@ export const links: LinksFunction = () => {
export const handle: SendouRouteHandle = {
navItemName: "plus",
breadcrumb: () => ({
imgPath: navIconUrl("plus"),
href: plusSuggestionPage(),
type: "IMAGE",
}),
};
export default function PlusPageLayout() {
return (
<>
<Main>
<SubNav>
<SubNavLink to="suggestions">Suggestions</SubNavLink>
<SubNavLink to="voting/results">Results</SubNavLink>
<SubNavLink to="voting">Voting</SubNavLink>
</SubNav>
<Main>
<Outlet />
</Main>
</>
<Outlet />
</Main>
);
}

View File

@ -258,7 +258,7 @@ function Voting(data: Extract<PlusVotingLoaderData, { type: "voting" }>) {
<div className="stack md items-center">
<Avatar user={currentUser.user} size="lg" />
<h2>{discordFullName(currentUser.user)}</h2>
<div className="stack horizontal md">
<div className="stack horizontal lg">
<Button
className="plus-voting__vote-button downvote"
variant="outlined"

View File

@ -1,5 +0,0 @@
import { type LoaderFunction, redirect } from "@remix-run/node";
export { action } from "~/modules/theme";
export const loader: LoaderFunction = () => redirect("/", { status: 404 });

View File

@ -26,6 +26,7 @@ import {
userPage,
userResultsPage,
} from "~/utils/urls";
import { Main } from "~/components/Main";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@ -98,7 +99,7 @@ export default function UserPageLayout() {
useReplaceWithCustomUrl();
return (
<>
<Main>
<SubNav>
<SubNavLink to={userPage(data)}>{t("header.profile")}</SubNavLink>
{isOwnPage && (
@ -118,7 +119,7 @@ export default function UserPageLayout() {
)}
</SubNav>
<Outlet />
</>
</Main>
);
}

View File

@ -1,13 +1,12 @@
import type { ActionFunction } from "@remix-run/node";
import { json, type LoaderArgs } from "@remix-run/node";
import { useLoaderData, useMatches } from "@remix-run/react";
import { useTranslation } from "~/hooks/useTranslation";
import { z } from "zod";
import { BuildCard } from "~/components/BuildCard";
import { LinkButton } from "~/components/Button";
import { Main } from "~/components/Main";
import { BUILD } from "~/constants";
import { db } from "~/db";
import { useTranslation } from "~/hooks/useTranslation";
import { getUser, requireUser, useUser } from "~/modules/auth";
import { atOrError } from "~/utils/arrays";
import {
@ -17,7 +16,7 @@ import {
} from "~/utils/remix";
import { userNewBuildPage } from "~/utils/urls";
import { actualNumber, id } from "~/utils/zod";
import { type UserPageLoaderData, userParamsSchema } from "../../u.$identifier";
import { userParamsSchema, type UserPageLoaderData } from "../../u.$identifier";
const buildsActionSchema = z.object({
buildToDeleteId: z.preprocess(actualNumber, id),
@ -70,7 +69,7 @@ export default function UserBuildsPage() {
const isOwnPage = user?.id === parentPageData.id;
return (
<Main className="stack lg">
<div className="stack lg">
{data.builds.length < BUILD.MAX_COUNT && isOwnPage && (
<div className="stack items-end">
<LinkButton to={userNewBuildPage(parentPageData)} tiny>
@ -89,6 +88,6 @@ export default function UserBuildsPage() {
{t("noBuilds")}
</div>
)}
</Main>
</div>
);
}

View File

@ -12,17 +12,16 @@ import {
} from "@remix-run/react";
import { countries } from "countries-list";
import * as React from "react";
import { useTranslation } from "~/hooks/useTranslation";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Button } from "~/components/Button";
import { FormErrors } from "~/components/FormErrors";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { USER } from "~/constants";
import { db } from "~/db";
import { type User } from "~/db/types";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUser } from "~/modules/auth";
import { i18next } from "~/modules/i18n";
import styles from "~/styles/u-edit.css";
@ -180,7 +179,7 @@ export default function UserEditPage() {
const transition = useTransition();
return (
<Main halfWidth>
<div className="half-width">
<Form className="u-edit__container" method="post">
<CustomUrlInput parentRouteData={parentRouteData} />
<InGameNameInputs parentRouteData={parentRouteData} />
@ -196,7 +195,7 @@ export default function UserEditPage() {
</Button>
<FormErrors namespace="user" />
</Form>
</Main>
</div>
);
}

View File

@ -8,7 +8,6 @@ import { Badge } from "~/components/Badge";
import { TwitchIcon } from "~/components/icons/Twitch";
import { TwitterIcon } from "~/components/icons/Twitter";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { Main } from "~/components/Main";
import { rawSensToString } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { assertUnreachable } from "~/utils/types";
@ -26,39 +25,37 @@ export default function UserInfoPage() {
const data = parentRoute.data as UserPageLoaderData;
return (
<Main>
<div className="u__container">
<div className="u__avatar-container">
<Avatar user={data} size="lg" className="u__avatar" />
<h2 className="u__name">
{data.discordName}
<span className="u__discriminator">
<wbr />#{data.discordDiscriminator}
</span>
</h2>
{data.country ? (
<div className="u__country">
<span className="u__country-emoji">{data.country.emoji}</span>{" "}
<span className="u__country-name">{data.country.name}</span>
</div>
) : null}
<div className="u__socials">
{data.twitch ? (
<SocialLink type="twitch" identifier={data.twitch} />
) : null}
{data.twitter ? (
<SocialLink type="twitter" identifier={data.twitter} />
) : null}
{data.youtubeId ? (
<SocialLink type="youtube" identifier={data.youtubeId} />
) : null}
<div className="u__container">
<div className="u__avatar-container">
<Avatar user={data} size="lg" className="u__avatar" />
<h2 className="u__name">
{data.discordName}
<span className="u__discriminator">
<wbr />#{data.discordDiscriminator}
</span>
</h2>
{data.country ? (
<div className="u__country">
<span className="u__country-emoji">{data.country.emoji}</span>{" "}
<span className="u__country-name">{data.country.name}</span>
</div>
) : null}
<div className="u__socials">
{data.twitch ? (
<SocialLink type="twitch" identifier={data.twitch} />
) : null}
{data.twitter ? (
<SocialLink type="twitter" identifier={data.twitter} />
) : null}
{data.youtubeId ? (
<SocialLink type="youtube" identifier={data.youtubeId} />
) : null}
</div>
<ExtraInfos />
<BadgeContainer badges={data.badges} />
{data.bio && <article>{data.bio}</article>}
</div>
</Main>
<ExtraInfos />
<BadgeContainer badges={data.badges} />
{data.bio && <article>{data.bio}</article>}
</div>
);
}

View File

@ -1,9 +1,8 @@
import { useMatches } from "@remix-run/react";
import { useTranslation } from "~/hooks/useTranslation";
import invariant from "tiny-invariant";
import { LinkButton } from "~/components/Button";
import { Main } from "~/components/Main";
import { Section } from "~/components/Section";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { userResultsEditHighlightsPage } from "~/utils/urls";
import type { UserPageLoaderData } from "../../u.$identifier";
@ -31,7 +30,7 @@ export default function UserResultsPage() {
(isOwnResultsPage && hasResults && userPageData.results.length !== 1);
return (
<Main className="stack lg">
<div className="stack lg">
{showHighlightsSection && (
<Section
title={t("results.highlights")}
@ -64,6 +63,6 @@ export default function UserResultsPage() {
<UserResultsTable id="user-results-table" results={nonHighlights} />
</Section>
)}
</Main>
</div>
);
}

View File

@ -1,7 +1,7 @@
.builds__top-container {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
}
.builds__category {
@ -9,9 +9,9 @@
flex-direction: column;
padding: var(--s-3);
border-radius: var(--rounded);
background-color: var(--bg-darker);
background-color: var(--bg-lighter);
font-size: var(--fonts-sm);
font-weight: var(--semi-bold);
font-weight: 600;
gap: var(--s-4);
}
@ -38,5 +38,5 @@
.builds__category__weapon__img {
border-radius: var(--rounded);
background-color: var(--bg-lighter);
background-color: var(--bg-darker);
}

View File

@ -2,14 +2,13 @@
*::before,
*::after {
box-sizing: border-box;
color: var(--text);
}
body {
width: 100%;
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
font-family: Lexend, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: antialiased;
line-height: 1.55;
@ -61,7 +60,7 @@ a {
}
:is(button, .button).outlined {
background-color: transparent;
background-color: var(--theme-very-transparent);
color: var(--theme);
}
@ -137,12 +136,11 @@ textarea:not(.plain) {
max-width: 100%;
height: 8rem;
padding: var(--s-2-5) var(--s-3);
border: 1px solid var(--border);
border: 2px solid var(--border);
border-radius: var(--rounded);
accent-color: var(--theme-secondary);
background-color: transparent;
background-color: var(--bg-input);
color: var(--text);
font-size: var(--fonts-sm);
outline: none;
overflow-wrap: normal;
overflow-x: auto;
@ -167,12 +165,11 @@ input:not(.plain, [type="radio"]) {
input:not(.plain) {
height: 1rem;
padding: var(--s-4) var(--s-3);
border: 1px solid var(--border);
border: 2px solid var(--border);
border-radius: var(--rounded);
accent-color: var(--theme-secondary);
background-color: transparent;
background-color: var(--bg-input);
color: var(--text);
font-size: var(--fonts-sm);
outline: none;
}
@ -233,7 +230,7 @@ details summary {
fieldset {
border: none;
border-radius: var(--rounded);
background-color: var(--bg-darker-transparent);
background-color: var(--bg-lighter);
font-size: var(--fonts-sm);
padding-block-end: var(--s-3);
padding-inline: var(--s-3);
@ -254,16 +251,15 @@ select {
all: unset;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
border: 2px solid var(--border);
border-radius: var(--rounded);
background: var(--select-background, var(--bg-lighter));
background: var(--select-background, var(--bg-input));
/* TODO: Get color from CSS var */
background-image: url('data:image/svg+xml;utf8,<svg width="1rem" color="rgb(255 255 255 / 55%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>');
background-position: center right var(--s-3);
background-repeat: no-repeat;
cursor: pointer;
font-size: var(--fonts-sm);
font-weight: 500;
padding-block: 3.5px;
padding-inline: var(--s-3) var(--s-8);
@ -418,10 +414,10 @@ dialog::backdrop {
.input-container {
display: flex;
border: 1px solid var(--border);
border: 2px solid var(--border);
border-radius: var(--rounded);
accent-color: var(--theme-secondary);
background-color: transparent;
background-color: var(--bg-input);
color: var(--text);
font-size: var(--fonts-sm);
outline: none;
@ -460,40 +456,32 @@ dialog::backdrop {
display: flex;
flex-wrap: wrap;
justify-content: center;
background-color: var(--bg-lighter);
background-image: url("/static-assets/svg/background-pattern.svg");
margin-block-end: var(--s-4);
overflow-x: auto;
gap: var(--s-4);
margin-block-end: var(--s-8);
margin-block-start: -12px;
}
.sub-nav__link {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
padding: var(--s-4);
background-color: var(--bg-lighter);
max-width: 100px;
flex: 1;
padding: var(--s-1) var(--s-2);
border-radius: var(--rounded);
background-color: var(--bg-lightest);
color: var(--text);
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
text-align: center;
white-space: nowrap;
}
.sub-nav__active-icon {
height: 1.2rem;
margin-block-end: -1rem;
margin-block-start: -3px;
visibility: hidden;
}
.sub-nav__link.active > .sub-nav__active-icon {
visibility: visible;
.sub-nav__link.active {
color: var(--theme);
}
.popover-content {
z-index: 1;
max-width: 20rem;
padding: var(--s-2);
padding: var(--s-4);
border-radius: var(--rounded);
background-color: var(--bg-darker-transparent);
font-size: var(--fonts-sm);
@ -501,6 +489,19 @@ dialog::backdrop {
white-space: pre-wrap;
}
.articles-list {
display: flex;
flex-direction: column;
padding: 0;
gap: var(--s-6);
list-style: none;
}
.articles-list__title {
color: var(--theme);
font-size: var(--fonts-md);
}
@supports ((-webkit-backdrop-filter: none) or (backdrop-filter: none)) {
.popover-content {
-webkit-backdrop-filter: blur(10px) brightness(75%);
@ -647,8 +648,8 @@ dialog::backdrop {
.avatar {
border-radius: 50%;
background-color: var(--bg-lighter);
background-image: url("/static-assets/svg/background-pattern.svg");
background-color: var(--bg-lightest);
background-image: url("/static-assets/svg/new-background-pattern.svg");
}
.alert > svg {
@ -935,10 +936,3 @@ dialog::backdrop {
.ability-selector__ability-button.is-dragging {
box-shadow: 0 0 100px inset rgb(255 255 255 / 25%);
}
.breadcrumbs {
display: flex;
font-size: var(--fonts-xs);
font-weight: var(--bold);
gap: var(--s-1);
}

View File

@ -1,171 +1,120 @@
.front__logo-container {
text-align: center;
}
.front__logo-container > h1 {
font-size: var(--fonts-lg);
}
.front__logo-container > h2 {
color: var(--text-lighter);
font-size: var(--fonts-sm);
}
.front__builds-wrapper {
overflow-x: auto;
}
.front__builds-container {
width: 46.5rem;
grid-template-columns: 15rem 15rem 15rem;
}
.front__go-to-page-banner {
display: flex;
align-items: center;
padding: var(--s-2);
border-radius: var(--rounded);
background-color: var(--bg-lighter);
color: var(--text);
font-size: var(--fonts-sm);
gap: var(--s-2);
}
.front__go-to-page-banner__arrow-right {
max-width: 2rem;
margin-left: auto;
fill: var(--theme);
transition: transform 0.5s;
}
.front__go-to-page-banner:hover > .front__go-to-page-banner__arrow-right {
transform: translateX(5px);
}
.front__go-to-page-banner__nav-img-container {
min-width: max-content;
padding-inline-end: var(--s-2);
}
.front__calendar-header {
font-size: var(--fonts-sm);
text-align: center;
}
.front__calendar-peek-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-6);
}
.front__calendar-peek-container > div {
min-width: 18rem;
flex: 1 1 0px;
padding: var(--s-2);
border-radius: var(--rounded);
background-color: var(--bg-lighter);
}
.front__calendar-peek-container > h2 {
font-size: var(--fonts-md);
}
.front__event {
--tags-max-width: 999rem;
.front__nav-items-container {
display: grid;
flex: 1 1 0px;
border-radius: var(--rounded);
background-color: var(--bg-darker);
font-size: var(--fonts-sm);
grid-template-areas: "name secondary" "content content";
grid-template-columns: 1fr 1fr;
padding-block: var(--s-1-5);
padding-inline: var(--s-2-5);
row-gap: var(--s-2);
max-width: 340px;
margin: 0 auto;
column-gap: var(--s-4);
font-size: 13px;
grid-template-columns: 1fr 1fr 1fr;
row-gap: var(--s-4);
}
.front__event-name {
.front__nav-item {
display: flex;
flex-direction: column;
align-items: center;
color: var(--text);
font-weight: var(--semi-bold);
grid-area: name;
line-height: 1.35;
font-weight: bold;
gap: var(--s-1);
text-align: center;
}
.front__event-time {
color: var(--text-lighter);
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
.front__log-in-button {
display: grid;
width: 50px;
height: 50px;
border: none;
border-radius: 100%;
background-color: var(--bg-lighter);
place-items: center;
transition: all 0.2s ease-out;
}
.front__nav-image-container {
display: grid;
width: 75px;
height: 75px;
border-radius: var(--rounded);
background-color: var(--bg-lighter);
place-items: center;
transition: all 0.2s ease-out;
}
.front__nav-image-container.round {
width: 50px;
height: 50px;
border-radius: 100%;
}
.front__avatar {
width: 50px;
height: 50px;
}
.front__nav-image-container:hover {
background-color: var(--theme-transparent);
}
.front__log-out-container {
display: flex;
justify-content: center;
}
.front__drawings {
display: grid;
grid-template-columns: 1fr 1fr;
margin-block-start: var(--s-8);
margin-inline-end: -30px;
}
.front__drawing-img {
max-height: 175px;
}
@media screen and (min-width: 400px) {
.front__drawing-img {
max-height: 200px;
}
}
@media screen and (min-width: 600px) {
.front__drawing-img {
max-height: 300px;
}
}
@media screen and (min-width: 800px) {
.front__drawing-img {
max-height: 400px;
}
}
.front__drawings__boy {
z-index: 10;
grid-column: 1 / 2;
grid-row: 1;
justify-self: flex-end;
}
.front__event-secondary {
grid-area: secondary;
.front__drawings__girl {
z-index: 10;
grid-column: 2 / 3;
grid-row: 1;
}
.front__event-winners {
display: flex;
flex-wrap: wrap;
padding: 0;
column-gap: var(--s-3);
font-size: var(--fonts-xxs);
list-style: none;
.front__drawings__girl.bg {
z-index: 1;
}
.front__event-content-below {
display: flex;
align-items: flex-end;
grid-area: content;
.front__drawings__boy.bg {
z-index: 1;
}
.front__articles {
display: flex;
flex-direction: column;
padding: 0;
gap: var(--s-2);
list-style: none;
}
@media screen and (min-width: 640px) {
.front__nav-item.round {
display: none;
}
.front__articles a {
color: var(--text);
font-size: var(--fonts-md);
}
.front__more-features {
color: var(--text-lighter);
font-size: var(--fonts-lg);
text-align: center;
}
.front__feature-cards {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-2);
}
.front__feature-card {
min-width: 10rem;
max-width: 12rem;
flex: 1 1 0;
padding: var(--s-2);
border-radius: var(--rounded);
background-color: var(--bg-lighter);
color: var(--text);
text-align: center;
}
.front__feature-card__nav-icon {
margin: 0 auto;
}
.front__feature-card__title {
font-size: var(--fonts-md);
padding-block: var(--s-1);
}
.front__feature-card__description {
color: var(--text-lighter);
font-size: var(--fonts-xxs);
.front__log-out-container {
display: none;
}
}

View File

@ -4,26 +4,48 @@
flex-direction: column;
}
.layout__header {
--item-size: 2.25rem;
position: relative;
z-index: 501;
.layout__breadcrumb-container {
display: flex;
justify-content: space-between;
padding: var(--s-4);
background-color: var(--bg);
/** check if should use px or not */
height: 30px;
align-items: center;
gap: var(--s-2);
}
.layout__logo {
.layout__breadcrumb {
overflow: hidden;
max-width: 350px;
color: var(--text);
font-size: var(--fonts-sm);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
/** use css var */
.layout__breadcrumb-separator {
font-size: 20px;
}
.layout__header {
/** xxx: remove */
--item-size: 1.9rem;
position: fixed;
z-index: 501;
top: 0;
display: flex;
width: var(--item-size);
height: var(--item-size);
width: 100%;
align-items: center;
justify-content: center;
border-radius: var(--rounded);
background-color: var(--bg-lighter);
background-image: url("/static-assets/svg/background-pattern.svg");
justify-content: space-between;
border-bottom: 1.5px solid var(--border);
-webkit-backdrop-filter: blur(10px) brightness(75%);
backdrop-filter: blur(10px) brightness(75%);
background-color: transparent;
font-weight: bold;
padding-block: var(--s-2);
padding-inline: var(--s-4);
}
.layout__avatar {
@ -40,20 +62,6 @@
letter-spacing: 0.02rem;
}
.layout__page-title-header {
display: flex;
width: max-content;
align-items: center;
border-radius: 0 var(--rounded) var(--rounded) 0;
background-color: var(--bg-darker);
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
gap: var(--s-1);
padding-block: var(--s-1);
padding-inline-end: var(--s-4);
padding-inline-start: var(--s-2);
}
.layout__header__right-container {
display: flex;
gap: var(--s-3);
@ -65,7 +73,7 @@
height: var(--item-size);
padding: 0.25rem;
border: 2px solid;
border-color: var(--theme-transparent-vibrant);
border-color: var(--theme-transparent);
border-radius: 50%;
background-color: transparent;
color: inherit;
@ -83,126 +91,15 @@
padding-inline: var(--s-3);
}
.main.half-width {
.half-width {
width: 100%;
max-width: 24rem;
margin: 0 auto;
}
.layout__main {
padding-block-end: var(--s-32);
padding-block-start: var(--s-4);
}
.layout__burger {
display: flex;
width: var(--item-size);
height: var(--item-size);
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0.25rem;
border: 2px solid;
border-color: var(--theme-transparent-vibrant);
border-radius: 50%;
background-color: transparent;
color: inherit;
cursor: pointer;
gap: 2px;
}
.layout__burger__top-line {
transform: none;
transform-origin: 16px 10px;
transition-duration: 150ms;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.layout__burger__top-line.expanded {
transform: translateY(7px) rotate(45deg);
}
.layout__burger__middle-line {
opacity: 1;
transition-duration: 150ms;
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.layout__burger__middle-line.expanded {
opacity: 0;
}
.layout__burger__bottom-line {
transform: none;
transform-origin: 16px 22px;
transition-duration: 150ms;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.layout__burger__bottom-line.expanded {
transform: translateY(-5px) rotate(-45deg);
}
.layout__menu {
position: fixed;
z-index: 500;
top: 0;
right: 0;
width: max-content;
height: max-content;
margin-top: 5rem;
background-color: var(--bg-lighter);
background-image: url("/static-assets/svg/background-pattern.svg");
border-end-start-radius: var(--rounded);
border-start-start-radius: var(--rounded);
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.5s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.layout__menu.expanded {
transform: none;
}
.layout__menu__link__icon {
width: 2rem;
height: 2rem;
border-radius: var(--rounded);
background-color: var(--bg-lighter);
background-image: url("/static-assets/svg/background-pattern.svg");
}
.layout__menu__links {
display: grid;
justify-content: center;
grid-auto-columns: 1fr;
grid-auto-rows: 4rem;
padding-block: var(--s-4);
}
.layout__menu__link {
display: flex;
align-items: center;
padding: 0 var(--s-4);
border-top: 3px solid var(--bg-lighter);
margin: 0 var(--s-4);
background-color: var(--bg);
color: var(--text);
font-size: var(--fonts-sm);
font-weight: var(--bold);
gap: var(--s-2);
text-decoration: none;
text-transform: capitalize;
}
.layout__menu__link.first {
border-radius: var(--rounded) var(--rounded) 0 0;
}
.layout__menu__link.last {
border-radius: 0 0 var(--rounded) var(--rounded);
padding-block-start: var(--s-20);
}
.layout__log-in-button {
@ -212,7 +109,7 @@
justify-content: center;
padding: 0.5rem;
border: 2px solid;
border-color: var(--bg-lighter);
border-color: var(--theme-transparent);
border-radius: var(--rounded);
background-color: transparent;
color: inherit;
@ -245,7 +142,7 @@
display: flex;
flex-direction: column;
padding: var(--s-2-5);
background-color: var(--bg-darker);
background-color: var(--bg-lighter);
gap: var(--s-6);
margin-block-start: auto;
}
@ -277,7 +174,7 @@
justify-content: space-between;
padding: var(--s-4);
border-radius: var(--rounded);
background-color: var(--theme-transparent-vibrant);
background-color: var(--theme-transparent);
cursor: pointer;
font-size: var(--fonts-lg);
}
@ -287,10 +184,6 @@
transition: transform 0.25s ease-in-out;
}
.layout__footer__social-link:hover {
background-color: var(--theme-semi-transparent-vibrant);
}
.layout__footer__social-link:hover > .layout__footer__social-icon {
transform: translateY(-0.3rem);
}
@ -329,13 +222,44 @@
text-align: center;
}
.layout__side-nav {
position: fixed;
display: flex;
height: 100vh;
flex-direction: column;
justify-content: center;
gap: var(--s-6);
overflow-y: auto;
padding-inline: var(--s-4);
}
.layout__side-nav-image-container {
display: grid;
width: 40px;
height: 40px;
border-radius: var(--rounded);
background-color: var(--bg-lighter);
place-items: center;
transition: all 0.2s ease-out;
}
.layout__side-nav-image-container:hover {
background-color: var(--theme-transparent);
}
@media screen and (max-width: 900px) {
.layout__side-nav {
display: none;
}
}
@media screen and (max-width: 640px) {
.layout__header {
--item-size: 2.5rem;
.layout__header__right-container {
display: none;
}
.layout__header__button__icon {
width: 1.3rem;
.layout__breadcrumb-container > a {
max-width: 175px;
}
.layout__footer__socials {

View File

@ -4,6 +4,16 @@
gap: var(--s-5);
}
@media screen and (min-width: 640px) {
.object-damage__controls {
justify-content: center;
}
}
.object-damage__ability {
position: absolute;
}
.object-damage__ap-label {
display: flex;
justify-content: center;
@ -29,7 +39,7 @@
top: 0;
width: 100%;
border-radius: var(--rounded);
background-color: var(--bg-darker);
background-color: var(--bg-lightest);
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
padding-block: var(--s-2);
@ -44,6 +54,12 @@
margin-block-start: var(--s-1);
}
.object-damage__receiver-image {
padding: var(--s-2);
border-radius: var(--rounded);
background-color: var(--bg-lightest);
}
.object-damage__table-card {
display: grid;
width: 100%;

View File

@ -15,6 +15,8 @@ button[data-state="closed"][aria-haspopup="dialog"] {
.layout__header {
z-index: 2;
border: none;
backdrop-filter: none;
background-color: transparent;
}

View File

@ -67,7 +67,7 @@
.plus-history__suggestion-s {
border-radius: 50%;
background-color: var(--bg-lighter);
background-color: var(--bg);
color: var(--text);
font-weight: var(--bold);
margin-inline-end: var(--s-1);

View File

@ -99,14 +99,12 @@
}
.plus-voting__vote-button {
width: 6rem;
height: 2.5rem;
color: var(--text) !important;
font-size: var(--fonts-md);
width: 4rem;
}
.plus-voting__vote-button.downvote {
border-color: var(--theme-error-transparent);
color: var(--theme-error);
outline-color: var(--theme-error);
}

View File

@ -23,7 +23,7 @@
}
.u-edit__sens-select {
width: 4.8rem;
width: 6rem;
}
.u-edit__bio-container {

View File

@ -1,41 +1,40 @@
html {
--bg: hsl(202deg 100% 96%);
--bg-darker: hsl(202deg 90% 90%);
--bg-lighter: hsl(225deg 100% 88%);
--bg-lighter-transparent: hsla(225deg 100% 88% / 50%);
--bg-darker-very-transparent: hsl(202deg 90% 90% / 50%);
--bg-darker-transparent: hsla(202deg 90% 90% / 65%);
--bg-ability: rgb(3 6 7);
:root {
--bg: #02011e;
--bg-darker: #0a092d;
--bg-lighter: rgb(169 138 255 / 10%);
--bg-lighter-transparent: rgb(64 67 108 / 50%);
--bg-lightest: rgb(169 138 255 / 30%);
--bg-darker-very-transparent: hsla(237.3deg 42.3% 26.6% / 50%);
--bg-darker-transparent: #0a092dce;
--bg-ability: #010112;
--bg-badge: #000;
--bg-mode-active: hsl(255deg 66.7% 50% / 40%);
--abilities-button-bg: hsl(237deg 32% 30%);
--badge-text: rgb(255 255 255 / 95%);
--border: hsl(237deg 100% 86%);
--button-text: rgb(255 255 255 / 85%);
--bg-mode-active: var(--theme-transparent);
--bg-input: #1b1a35;
--abilities-button-bg: var(--bg-lighter);
--border: rgb(255 255 255 / 10%);
--button-text: rgb(0 0 0 / 85%);
--button-text-transparent: rgb(0 0 0 / 65%);
--text: rgb(0 0 0 / 95%);
--text: #e1dede;
--badge-text: var(--text);
--black-text: rgb(0 0 0 / 95%);
--text-lighter: rgb(75 75 75 / 95%);
--divider: #635dab;
--theme-error: rgb(199 13 6);
--theme-error-transparent: rgba(199 13 6 / 55%);
--theme-warning: #c9c900;
--theme-warning-transparent: #c9c90052;
--theme-success: #00a514;
--theme-success-transparent: #00a51452;
--theme-info: #1fb0d0;
--theme-info-transparent: #1fb0d052;
--theme-informative-yellow: #b09901;
--theme-informative-red: #9d0404;
--theme-informative-blue: #007f9c;
--theme-informative-green: #017a0f;
--theme: hsl(255deg 64% 63%);
--theme-vibrant: hsl(255deg 100% 81%);
--theme-transparent: hsl(255deg 66.7% 75% / 40%);
--theme-very-transparent: hsl(255deg 66.7% 75% / 30%);
--theme-transparent-vibrant: hsl(255deg 100% 81% / 54%);
--theme-semi-transparent-vibrant: hsl(255deg 100% 81% / 75%);
--text-lighter: rgb(215 214 255 / 80%);
--theme-error: rgb(219 70 65);
--theme-error-transparent: rgba(219 70 65 / 55%);
--theme-warning: #f5f587;
--theme-success: #a3ffae;
--theme-success-transparent: #a3ffae52;
--theme-info: #87cddc;
--theme-info-transparent: #87cddc52;
--theme-informative-yellow: #ffed75;
--theme-informative-red: #ff9494;
--theme-informative-blue: #a7efff;
--theme-informative-green: #a2ffad;
--theme: #ffc6de;
--theme-very-transparent: #ffc6de36;
--theme-vibrant: hsl(255deg 78% 65%);
--theme-transparent: #ffc6de52;
--theme-secondary: hsl(85deg 66.7% 55.3%);
--inactive-image-filter: grayscale(100%) brightness(130%);
--rounded: 16px;
--rounded-full: 200px;
--rounded-sm: 10px;
@ -48,8 +47,8 @@ html {
--fonts-xxxs: 0.6rem;
--fonts-xxxxs: 0.5rem;
--extra-bold: 700;
--bold: 600;
--semi-bold: 500;
--bold: 700;
--semi-bold: 600;
--body: 400;
--s-1: 0.25rem;
--s-1-5: 0.375rem;
@ -80,44 +79,6 @@ html {
--s-96: 2rem;
--sparse: 0.4px;
--label-margin: var(--s-1);
--inactive-image-filter: grayscale(100%) brightness(30%);
}
html.dark {
--bg: hsl(237.3deg 42.3% 30.6%);
--bg-darker: hsl(237.3deg 42.3% 26.6%);
--bg-lighter: hsl(237.3deg 42.3% 35.6%);
--bg-lighter-transparent: rgb(64 67 108 / 50%);
--bg-darker-very-transparent: hsla(237.3deg 42.3% 26.6% / 50%);
--bg-darker-transparent: hsla(237.3deg 42.3% 26.6% / 90%);
--bg-ability: rgb(17 19 43);
--bg-badge: #000;
--bg-mode-active: var(--theme-transparent);
--abilities-button-bg: hsl(237.3deg 42.3% 26.6%);
--border: hsl(237.3deg 42.3% 45.6%);
--button-text: rgb(0 0 0 / 85%);
--button-text-transparent: rgb(0 0 0 / 65%);
--text: rgb(255 255 255 / 95%);
--black-text: rgb(0 0 0 / 95%);
--text-lighter: rgb(215 214 255 / 80%);
--theme-error: rgb(219 70 65);
--theme-error-transparent: rgba(219 70 65 / 55%);
--theme-warning: #f5f587;
--theme-success: #a3ffae;
--theme-success-transparent: #a3ffae52;
--theme-info: #87cddc;
--theme-info-transparent: #87cddc52;
--theme-informative-yellow: #ffed75;
--theme-informative-red: #ff9494;
--theme-informative-blue: #a7efff;
--theme-informative-green: #a2ffad;
--theme: hsl(255deg 66.7% 75%);
--theme-vibrant: hsl(255deg 78% 65%);
--theme-transparent: hsl(255deg 66.7% 75% / 40%);
--theme-transparent-vibrant: hsl(255deg 78% 65% / 54%);
--theme-semi-transparent-vibrant: hsl(255deg 78% 65% / 75%);
--theme-secondary: hsl(85deg 66.7% 55.3%);
--inactive-image-filter: grayscale(100%) brightness(130%);
}
html.dark .light-mode-only {

View File

@ -93,6 +93,10 @@ export function validate(condition: any, status = 400): asserts condition {
throw new Response(null, { status });
}
export type Breadcrumb =
| { imgPath: string; type: "IMAGE"; href: string }
| { text: string; type: "TEXT"; href: string };
/**
* Our custom type for route handles - the keys are defined by us or
* libraries that parse them.
@ -111,7 +115,7 @@ export type SendouRouteHandle = {
breadcrumb?: (args: {
match: RouteMatch;
t: TFunction<"common", undefined>;
}) => string | undefined;
}) => Breadcrumb | Array<Breadcrumb>;
/** The name of a navItem that is active on this route. See nav-items.json */
navItemName?: typeof navItems[number]["name"];

View File

@ -48,6 +48,9 @@ export const CALENDAR_PAGE = "/calendar";
export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
export const SEED_URL = "/seed";
export const PLANNER_URL = "/plans";
export const MAPS_URL = "/maps";
export const ANALYZER_URL = "/analyzer";
export const OBJECT_DAMAGE_CALCULATOR_URL = "/object-damage-calculator";
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
export const COMMON_PREVIEW_IMAGE =
@ -61,6 +64,10 @@ export const SECOND_PLACEMENT_ICON_PATH =
"/static-assets/svg/placements/second.svg";
export const THIRD_PLACEMENT_ICON_PATH =
"/static-assets/svg/placements/third.svg";
export const FRONT_BOY_PATH = "/static-assets/img/layout/front-boy";
export const FRONT_GIRL_PATH = "/static-assets/img/layout/front-girl";
export const FRONT_BOY_BG_PATH = "/static-assets/img/layout/front-boy-bg";
export const FRONT_GIRL_BG_PATH = "/static-assets/img/layout/front-girl-bg";
export const GET_ALL_USERS_ROUTE = "/users";
export const GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE = "/calendar/map-pool-events";

View File

@ -19,6 +19,7 @@
"check-translation-jsons:no-write": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/check-translation-jsons.ts --no-write",
"replace-img-names": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/replace-img-names.ts",
"replace-weapon-names": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/replace-weapon-names.ts",
"hex-to-filter": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/hex-to-filter.ts",
"lint:ts": "eslint . --ext .ts,.tsx",
"lint:styles": "stylelint \"app/styles/**/*.css\"",
"prettier:check": "prettier --check . --loglevel warn",

View File

@ -100,9 +100,7 @@
"weapon.category.STRINGERS": "Stringers/Buer",
"weapon.category.SPLATANAS": "Splatanas/Splatanaer",
"theme.light": "Lyst",
"theme.dark": "Mørkt",
"theme.auto": "Automatisk",
"plans.poweredBy": "Tegneprogrammet er leveret af {{name}}",
"plans.poweredBy": "Tegneprogrammet er leveret af {{name}}"
"websiteSubtitle": "Konkurrencepræget Splatoon-hub"
}

View File

@ -1,16 +0,0 @@
{
"websiteSubtitle": "Konkurrencepræget Splatoon-hub",
"buildsGoTo": "Kig på våbenspecifikke udrustningssæt, der er lavet af andre spillere.",
"calendarGoTo": "Se alle tidligere og kommende begivenheder på kalender-siden.",
"moreFeatures": "Flere funktioner",
"plus.description": "Se plus-serverens valghistorik og mere til.",
"badges.description": "Liste over alle de premiemærker, som man kan gøre dig fortjent til.",
"analyzer.description": "Undersøg hvordan dine udrustinger giver dig af fordele i kamp",
"maps.description": "Lav en liste af baner til at spille på ud fra en pulje af baner.",
"plans.description": "Tegneredskab til at planlægge kampe med både bane- og våbenillustrationer.",
"object-damage-calculator.description": "Her kan du udregne den påførte skade for forskellige objekter.",
"recentWinners": "De seneste vindere",
"upcomingEvents": "Kommende begivenheder",
"articleBy": "Af {{author}}",
"articlesGoTo": "Se alle udgivne artikler"
}

View File

@ -89,7 +89,5 @@
"weapon.category.STRINGERS": "Stringer",
"weapon.category.SPLATANAS": "Splatanas",
"theme.light": "Hell",
"theme.dark": "Dunkel",
"theme.auto": "Auto"
"websiteSubtitle": "Hub für Competitive Splatoon"
}

View File

@ -1,14 +0,0 @@
{
"websiteSubtitle": "Hub für Competitive Splatoon",
"buildsGoTo": "Durchsuche Ausrüstungen aller Waffen in Splatoon 3 von Top-Level-Spielern und anderen",
"calendarGoTo": "Sieh alle bisherigen und zukünftigen Events auf der Kalender-Seite",
"moreFeatures": "Weitere Funktionen",
"plus.description": "Sieh vergangene Plus Server Abstimmungen und mehr",
"badges.description": "Liste aller Abzeichen, die du für dein Profil verdienen kannst",
"analyzer.description": "Analysiere, was deine Ausrüstungen wirklich bewirken",
"maps.description": "Erstelle aus einem Arenen-Pool die Liste für dein Spiel",
"object-damage-calculator.description": "Berechne unterschiedlichen Objekten zugefügten Schaden",
"recentWinners": "Aktuelle Gewinner",
"upcomingEvents": "Bevorstehende Events",
"articleBy": "von {{author}}"
}

View File

@ -11,7 +11,8 @@
"pages.analyzer": "Build Analyzer",
"pages.maps": "Map Lists",
"pages.plans": "Planner",
"pages.object-damage-calculator": "Object DMG Calc",
"pages.object-damage-calculator": "DMG Calc",
"pages.myPage": "My Page",
"header.profile": "Profile",
"header.logout": "Log out",
@ -104,9 +105,9 @@
"weapon.category.STRINGERS": "Stringers",
"weapon.category.SPLATANAS": "Splatanas",
"theme.light": "Light",
"theme.dark": "Dark",
"theme.auto": "Auto",
"plans.poweredBy": "Powered by {{name}}",
"plans.poweredBy": "Powered by {{name}}"
"articles.by": "by {{author}}",
"websiteSubtitle": "Competitive Splatoon Hub"
}

View File

@ -1,16 +0,0 @@
{
"websiteSubtitle": "Competitive Splatoon Hub",
"buildsGoTo": "Browse builds of all Splatoon 3 weapons from top level players and others",
"calendarGoTo": "See all the past and upcoming events on the calendar page",
"moreFeatures": "More features",
"plus.description": "View Plus Server voting history and more",
"badges.description": "List of all the badges you can earn for your profile",
"analyzer.description": "Find out what your builds actually do",
"maps.description": "Turn pool of maps into a list to play on",
"plans.description": "Drawing tool to make plans using stage and weapon images",
"object-damage-calculator.description": "Calculate damage dealt to different objects",
"recentWinners": "Recent winners",
"upcomingEvents": "Upcoming events",
"articleBy": "by {{author}}",
"articlesGoTo": "View all articles from before"
}

View File

@ -54,5 +54,7 @@
"weapon.category.DUALIES": "Armas duales",
"weapon.category.BRELLAS": "Paratintas",
"weapon.category.STRINGERS": "Arcromatizador",
"weapon.category.SPLATANAS": "Azotintadores"
"weapon.category.SPLATANAS": "Azotintadores",
"websiteSubtitle": "Sitio Central de Splatoon Competitivo"
}

View File

@ -1,12 +0,0 @@
{
"websiteSubtitle": "Sitio Central de Splatoon Competitivo",
"buildsGoTo": "Explora builds hechos por jugadores de alto nivel y otros para todas las armas en Splatoon 3",
"calendarGoTo": "Revisa todos los eventos pasados y próximos en el calendario",
"moreFeatures": "Más funciones",
"plus.description": "Revisa el historial de votos del Plus Server y más",
"badges.description": "Lista de todas las insignias que puedes ganar para tu perfil",
"analyzer.description": "Descubre lo que hazen tus builds en realidad",
"recentWinners": "Ganadores recientes",
"upcomingEvents": "Eventos próximos",
"articleBy": "por {{author}}"
}

View File

@ -54,5 +54,7 @@
"weapon.category.DUALIES": "Armas duales",
"weapon.category.BRELLAS": "Paratintas",
"weapon.category.STRINGERS": "Arcromatizador",
"weapon.category.SPLATANAS": "Azotintadores"
"weapon.category.SPLATANAS": "Azotintadores",
"websiteSubtitle": "Sitio Central de Splatoon Competitivo"
}

View File

@ -1,12 +0,0 @@
{
"websiteSubtitle": "Sitio Central de Splatoon Competitivo",
"buildsGoTo": "Explora builds hechos por jugadores de alto nivel y otros para todas las armas en Splatoon 3",
"calendarGoTo": "Revisa todos los eventos pasados y próximos en el calendario",
"moreFeatures": "Más funciones",
"plus.description": "Revisa el historial de votos del Plus Server y más",
"badges.description": "Lista de todas las insignias que puedes ganar para tu perfil",
"analyzer.description": "Descubre lo que hazen tus builds en realidad",
"recentWinners": "Ganadores recientes",
"upcomingEvents": "Eventos próximos",
"articleBy": "por {{author}}"
}

View File

@ -54,5 +54,7 @@
"weapon.category.DUALIES": "Double encreurs",
"weapon.category.BRELLAS": "Para-encres",
"weapon.category.STRINGERS": "Trisperceurs",
"weapon.category.SPLATANAS": "Eclatanas"
"weapon.category.SPLATANAS": "Eclatanas",
"websiteSubtitle": "Hub du Splatoon Compétitif"
}

View File

@ -1,11 +0,0 @@
{
"websiteSubtitle": "Hub du Splatoon Compétitif",
"calendarGoTo": "Regardez tous les événements passés et à venir sur la page du calendrier",
"moreFeatures": "Plus de fonctionnalités",
"plus.description": "Regardez l'historique de vote du Plus Server et d'autres informations",
"badges.description": "La liste de tous les badges que vous pouvez recevoir sur votre profil",
"recentWinners": "Gagnants récents",
"upcomingEvents": "Événements à venir",
"articleBy": "par {{author}}",
"articlesGoTo": "Voir tous les articles d'avant"
}

View File

@ -102,7 +102,5 @@
"weapon.category.STRINGERS": "ストリンガー",
"weapon.category.SPLATANAS": "ワイパー",
"theme.light": "Light",
"theme.dark": "Dark",
"theme.auto": "自動"
"websiteSubtitle": "Competitive Splatoon Hub"
}

View File

@ -1,15 +0,0 @@
{
"websiteSubtitle": "Competitive Splatoon Hub",
"buildsGoTo": "トッププレイヤーや他のプレイヤーが使っている Splatoon 3 のブキのセットアップを見る",
"calendarGoTo": "過去・開催予定イベントを見る",
"moreFeatures": "その他の機能",
"plus.description": "Plus Server 投票の履歴を見る、その他",
"badges.description": "プロファイルに表示できるバッジのリスト",
"analyzer.description": "アナライザーでギアとブキの効果を見る",
"maps.description": "ステージリストをプレイリストに変換する",
"object-damage-calculator.description": "オブジェクトに対するダメージを計算する",
"recentWinners": "直近の優勝者",
"upcomingEvents": "開催予定のイベント",
"articleBy": "by {{author}}",
"articlesGoTo": "過去の記事を見る"
}

View File

@ -33,5 +33,7 @@
"tag.name.LOW": "실력 제한",
"tag.name.COUNT": "참가 제한",
"tag.name.LAN": "현장 이벤트",
"tag.name.QUALIFIER": "예선전"
"tag.name.QUALIFIER": "예선전",
"websiteSubtitle": "Competitive Splatoon Hub"
}

View File

@ -1,10 +0,0 @@
{
"websiteSubtitle": "Competitive Splatoon Hub",
"calendarGoTo": "달력에서 지나간 그리고 다가오는 이벤트들을 살펴보세요",
"moreFeatures": "그 외 기능",
"plus.description": "Plus Server 투표 기록 등 보기",
"badges.description": "프로필에 얻을 수 있는 배지 목록",
"recentWinners": "최근 우승자들",
"upcomingEvents": "다가오는 이벤트들",
"articleBy": "{{author}} 작성"
}

View File

@ -62,5 +62,7 @@
"weapon.category.DUALIES": "Dubbelknallers",
"weapon.category.BRELLAS": "Plenzers",
"weapon.category.STRINGERS": "Spanners",
"weapon.category.SPLATANAS": "Zwiepers"
"weapon.category.SPLATANAS": "Zwiepers",
"websiteSubtitle": "Competitief Splatoon Hub"
}

View File

@ -1,13 +0,0 @@
{
"websiteSubtitle": "Competitief Splatoon Hub",
"buildsGoTo": "Bekijk uitrustingen voor alle Splatoon 3 wapens van top niveau spelers en anderen.",
"calendarGoTo": "Bekijk alle afgelopen en komende evenementen in de kalender",
"moreFeatures": "Meer functies",
"plus.description": "Bekijk de Plus Server stemmingsuitslagen en meer",
"badges.description": "Lijst van alle badges die je kunt verdienen voor je profiel",
"analyzer.description": "Check wat je uitrustingen precies doen",
"maps.description": "Maak een levellijst om mee te spelen",
"recentWinners": "Recente winnaars",
"upcomingEvents": "Aankomende evenementen",
"articleBy": "door {{author}}"
}

View File

@ -104,9 +104,7 @@
"weapon.category.STRINGERS": "Тетиваторы",
"weapon.category.SPLATANAS": "Сплат-катаны",
"theme.light": "Светлая",
"theme.dark": "Тёмная",
"theme.auto": "Системная",
"plans.poweredBy": "При поддержке {{name}}",
"plans.poweredBy": "При поддержке {{name}}"
"websiteSubtitle": "Соревновательный Splatoon-хаб"
}

View File

@ -1,16 +0,0 @@
{
"websiteSubtitle": "Соревновательный Splatoon-хаб",
"buildsGoTo": "Изучайте сборки всех оружий в Splatoon 3 от лучших (и не только) игроков",
"calendarGoTo": "Посмотреть все прошлые и предстоящие события на странице календаря",
"moreFeatures": "Больше возможностей",
"plus.description": "Посмотреть историю голосования Plus Server (и не только)",
"badges.description": "Список всех значков, которые можно заработать для своего профиля",
"analyzer.description": "Узнайте, что на самом деле делают ваши сборки",
"maps.description": "Сделайте свой список на основе выбранного пула карт",
"plans.description": "Инструмент для рисования планов с изображениями карт и оружия",
"object-damage-calculator.description": "Расчитайте урон, наносимый различным объектам",
"recentWinners": "Недавние победители",
"upcomingEvents": "Предстоящие события",
"articleBy": "от {{author}}",
"articlesGoTo": "Посмотреть все прошлые статьи"
}

View File

@ -104,9 +104,7 @@
"weapon.category.STRINGERS": "猎鱼弓",
"weapon.category.SPLATANAS": "刮水刀",
"theme.light": "浅色模式",
"theme.dark": "深色模式",
"theme.auto": "自动",
"plans.poweredBy": "Powered by {{name}}",
"plans.poweredBy": "Powered by {{name}}"
"websiteSubtitle": "斯普拉遁竞技中心"
}

View File

@ -1,16 +0,0 @@
{
"websiteSubtitle": "斯普拉遁竞技中心",
"buildsGoTo": "查看来自顶尖玩家及其他玩家的斯普拉遁3全武器配装",
"calendarGoTo": "在日程页查看所有活动",
"moreFeatures": "更多功能",
"plus.description": "查看Plus Server的投票记录及其他",
"badges.description": "个人档案中可添加的所有徽章",
"analyzer.description": "查看你的配装的实际效果",
"maps.description": "制作可供游玩的地图列表",
"plans.description": "使用地图和武器图像来做规划的画图工具",
"object-damage-calculator.description": "计算对不同物体的伤害",
"recentWinners": "往期冠军",
"upcomingEvents": "近期活动",
"articleBy": "由{{author}}发表",
"articlesGoTo": "查看全部文章"
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE xml>
<!--https://www.heropatterns.com/-->
<svg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'>
<path d='M21.184 20c.357-.13.72-.264 1.088-.402l1.768-.661C33.64 15.347 39.647 14 50 14c10.271 0 15.362 1.222 24.629 4.928.955.383 1.869.74 2.75 1.072h6.225c-2.51-.73-5.139-1.691-8.233-2.928C65.888 13.278 60.562 12 50 12c-10.626 0-16.855 1.397-26.66 5.063l-1.767.662c-2.475.923-4.66 1.674-6.724 2.275h6.335zm0-20C13.258 2.892 8.077 4 0 4V2c5.744 0 9.951-.574 14.85-2h6.334zM77.38 0C85.239 2.966 90.502 4 100 4V2c-6.842 0-11.386-.542-16.396-2h-6.225zM0 14c8.44 0 13.718-1.21 22.272-4.402l1.768-.661C33.64 5.347 39.647 4 50 4c10.271 0 15.362 1.222 24.629 4.928C84.112 12.722 89.438 14 100 14v-2c-10.271 0-15.362-1.222-24.629-4.928C65.888 3.278 60.562 2 50 2 39.374 2 33.145 3.397 23.34 7.063l-1.767.662C13.223 10.84 8.163 12 0 12v2z' fill='#6741d9' fill-opacity='0.4' fill-rule='evenodd' />
</svg>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 18"><path fill="#ffc6de" d="M61.82 18c3.47-1.45 6.86-3.78 11.3-7.34C78 6.76 80.34 5.1 83.87 3.42 88.56 1.16 93.75 0 100 0v6.16C98.76 6.05 97.43 6 96 6c-9.59 0-14.23 2.23-23.13 9.34-1.28 1.03-2.39 1.9-3.4 2.66h-7.65zm-23.64 0H22.52c-1-.76-2.1-1.63-3.4-2.66C11.57 9.3 7.08 6.78 0 6.16V0c6.25 0 11.44 1.16 16.14 3.42 3.53 1.7 5.87 3.35 10.73 7.24 4.45 3.56 7.84 5.9 11.31 7.34zM61.82 0h7.66a39.57 39.57 0 0 1-7.34 4.58C57.44 6.84 52.25 8 46 8S34.56 6.84 29.86 4.58A39.57 39.57 0 0 1 22.52 0h15.66C41.65 1.44 45.21 2 50 2c4.8 0 8.35-.56 11.82-2z"/></svg>

After

Width:  |  Height:  |  Size: 608 B

353
scripts/hex-to-filter.ts Normal file
View File

@ -0,0 +1,353 @@
/* eslint-disable */
// @ts-nocheck
export {};
interface HSL {
h: number;
s: number;
l: number;
}
class Color {
public r: number;
public g: number;
public b: number;
constructor(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
toString() {
return `rgb(${Math.round(this.r)}, ${Math.round(this.g)}, ${Math.round(
this.b
)})`;
}
set(r: number, g: number, b: number) {
this.r = this.clamp(r);
this.g = this.clamp(g);
this.b = this.clamp(b);
}
hueRotate(angle = 0) {
angle = (angle / 180) * Math.PI;
const sin = Math.sin(angle);
const cos = Math.cos(angle);
this.multiply([
0.213 + cos * 0.787 - sin * 0.213,
0.715 - cos * 0.715 - sin * 0.715,
0.072 - cos * 0.072 + sin * 0.928,
0.213 - cos * 0.213 + sin * 0.143,
0.715 + cos * 0.285 + sin * 0.14,
0.072 - cos * 0.072 - sin * 0.283,
0.213 - cos * 0.213 - sin * 0.787,
0.715 - cos * 0.715 + sin * 0.715,
0.072 + cos * 0.928 + sin * 0.072,
]);
}
grayscale(value = 1) {
this.multiply([
0.2126 + 0.7874 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 + 0.2848 * (1 - value),
0.0722 - 0.0722 * (1 - value),
0.2126 - 0.2126 * (1 - value),
0.7152 - 0.7152 * (1 - value),
0.0722 + 0.9278 * (1 - value),
]);
}
sepia(value = 1) {
this.multiply([
0.393 + 0.607 * (1 - value),
0.769 - 0.769 * (1 - value),
0.189 - 0.189 * (1 - value),
0.349 - 0.349 * (1 - value),
0.686 + 0.314 * (1 - value),
0.168 - 0.168 * (1 - value),
0.272 - 0.272 * (1 - value),
0.534 - 0.534 * (1 - value),
0.131 + 0.869 * (1 - value),
]);
}
saturate(value = 1) {
this.multiply([
0.213 + 0.787 * value,
0.715 - 0.715 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 + 0.285 * value,
0.072 - 0.072 * value,
0.213 - 0.213 * value,
0.715 - 0.715 * value,
0.072 + 0.928 * value,
]);
}
multiply(matrix: any) {
const newR = this.clamp(
this.r * matrix[0] + this.g * matrix[1] + this.b * matrix[2]
);
const newG = this.clamp(
this.r * matrix[3] + this.g * matrix[4] + this.b * matrix[5]
);
const newB = this.clamp(
this.r * matrix[6] + this.g * matrix[7] + this.b * matrix[8]
);
this.r = newR;
this.g = newG;
this.b = newB;
}
brightness(value = 1) {
this.linear(value);
}
contrast(value = 1) {
this.linear(value, -(0.5 * value) + 0.5);
}
linear(slope = 1, intercept = 0) {
this.r = this.clamp(this.r * slope + intercept * 255);
this.g = this.clamp(this.g * slope + intercept * 255);
this.b = this.clamp(this.b * slope + intercept * 255);
}
invert(value = 1) {
this.r = this.clamp((value + (this.r / 255) * (1 - 2 * value)) * 255);
this.g = this.clamp((value + (this.g / 255) * (1 - 2 * value)) * 255);
this.b = this.clamp((value + (this.b / 255) * (1 - 2 * value)) * 255);
}
hsl(): HSL {
// Code taken from https://stackoverflow.com/a/9493060/2688027, licensed under CC BY-SA.
const r = this.r / 255;
const g = this.g / 255;
const b = this.b / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
let l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return {
h: h * 100,
s: s * 100,
l: l * 100,
};
}
clamp(value: number): number {
if (value > 255) {
value = 255;
} else if (value < 0) {
value = 0;
}
return value;
}
}
interface Solution {
loss: number;
values: number[];
}
class Solver {
private target: Color;
private targetHSL: HSL;
private reusedColor: Color;
constructor(target: Color) {
this.target = target;
this.targetHSL = target.hsl();
this.reusedColor = new Color(0, 0, 0);
}
solve() {
const result = this.solveNarrow(this.solveWide());
return {
values: result.values,
loss: result.loss,
filter: this.css(result.values),
};
}
solveWide(): Solution {
const A = 5;
const c = 15;
const a = [60, 180, 18000, 600, 1.2, 1.2];
let best = { loss: Infinity, values: [] as number[] };
for (let i = 0; best.loss > 25 && i < 3; i++) {
const initial = [50, 20, 3750, 50, 100, 100];
const result = this.spsa(A, a, c, initial, 1000);
if (result.loss < best.loss) {
best = result;
}
}
return best;
}
solveNarrow(wide: Solution) {
const A = wide.loss;
const c = 2;
const A1 = A + 1;
const a = [0.25 * A1, 0.25 * A1, A1, 0.25 * A1, 0.2 * A1, 0.2 * A1];
return this.spsa(A, a, c, wide.values, 500);
}
spsa(
A: number,
a: number[],
c: number,
values: number[],
iters: number
): Solution {
const alpha = 1;
const gamma = 0.16666666666666666;
let best = [] as number[];
let bestLoss = Infinity;
const deltas = new Array(6);
const highArgs = new Array(6);
const lowArgs = new Array(6);
for (let k = 0; k < iters; k++) {
const ck = c / Math.pow(k + 1, gamma);
for (let i = 0; i < 6; i++) {
deltas[i] = Math.random() > 0.5 ? 1 : -1;
highArgs[i] = values[i] + ck * deltas[i];
lowArgs[i] = values[i] - ck * deltas[i];
}
const lossDiff = this.loss(highArgs) - this.loss(lowArgs);
for (let i = 0; i < 6; i++) {
const g = (lossDiff / (2 * ck)) * deltas[i];
const ak = a[i] / Math.pow(A + k + 1, alpha);
values[i] = fix(values[i] - ak * g, i);
}
const loss = this.loss(values);
if (loss < bestLoss) {
best = values.slice(0);
bestLoss = loss;
}
}
return { values: best, loss: bestLoss };
function fix(value: number, idx: number): number {
let max = 100;
if (idx === 2 /* saturate */) {
max = 7500;
} else if (idx === 4 /* brightness */ || idx === 5 /* contrast */) {
max = 200;
}
if (idx === 3 /* hue-rotate */) {
if (value > max) {
value %= max;
} else if (value < 0) {
value = max + (value % max);
}
} else if (value < 0) {
value = 0;
} else if (value > max) {
value = max;
}
return value;
}
}
loss(filters: number[]) {
// Argument is array of percentages.
const color = this.reusedColor;
color.set(0, 0, 0);
color.invert(filters[0] / 100);
color.sepia(filters[1] / 100);
color.saturate(filters[2] / 100);
color.hueRotate(filters[3] * 3.6);
color.brightness(filters[4] / 100);
color.contrast(filters[5] / 100);
const colorHSL = color.hsl();
return (
Math.abs(color.r - this.target.r) +
Math.abs(color.g - this.target.g) +
Math.abs(color.b - this.target.b) +
Math.abs(colorHSL.h - this.targetHSL.h) +
Math.abs(colorHSL.s - this.targetHSL.s) +
Math.abs(colorHSL.l - this.targetHSL.l)
);
}
css(filters: number[]) {
function fmt(idx: number, multiplier = 1) {
return Math.round(filters[idx] * multiplier);
}
return `invert(${fmt(0)}%) sepia(${fmt(1)}%) saturate(${fmt(
2
)}%) hue-rotate(${fmt(3, 3.6)}deg) brightness(${fmt(4)}%) contrast(${fmt(
5
)}%)`;
}
}
type RGB = [number, number, number];
function hexToRgb(hex: string): RGB {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (_m, r, g, b) => {
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result) {
return [
parseInt(result[1], 16),
parseInt(result[2], 16),
parseInt(result[3], 16),
];
}
throw new Error("Error parsing hex: " + hex);
}
function hexToFilter(hex: string) {
let rgb = [255, 255, 255];
try {
rgb = hexToRgb(hex);
} catch (e) {}
const color = new Color(rgb[0], rgb[1], rgb[2]);
const solver = new Solver(color);
const result = solver.solve();
console.log(" --- RESULT ---");
console.log(result.filter);
}
hexToFilter(process.argv[2]?.trim());

View File

@ -1,7 +1,6 @@
import "react-i18next";
import type common from "../public/locales/en/common.json";
import type front from "../public/locales/en/front.json";
import type faq from "../public/locales/en/faq.json";
import type contributions from "../public/locales/en/contributions.json";
import type user from "../public/locales/en/user.json";
@ -19,7 +18,6 @@ declare module "react-i18next" {
defaultNS: "common";
resources: {
common: typeof common;
front: typeof front;
faq: typeof faq;
contributions: typeof contributions;
user: typeof user;