mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Redesign (#1179)
* 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:
parent
dacc475efb
commit
34ca290bdd
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
58
app/components/layout/LogInButtonContainer.tsx
Normal file
58
app/components/layout/LogInButtonContainer.tsx
Normal 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")}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
32
app/components/layout/SideNav.tsx
Normal file
32
app/components/layout/SideNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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")}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%)"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
Implements dark mode for a Remix app.
|
||||
|
||||
Based on https://github.com/remix-run/remix/blob/main/examples/dark-mode
|
||||
|
|
@ -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() } }
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { Theme, ThemeHead, ThemeProvider, useTheme } from "./provider";
|
||||
export { action } from "./action.server";
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
60
app/root.tsx
60
app/root.tsx
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { type LoaderFunction, redirect } from "@remix-run/node";
|
||||
|
||||
export { action } from "~/modules/theme";
|
||||
|
||||
export const loader: LoaderFunction = () => redirect("/", { status: 404 });
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ button[data-state="closed"][aria-haspopup="dialog"] {
|
|||
|
||||
.layout__header {
|
||||
z-index: 2;
|
||||
border: none;
|
||||
backdrop-filter: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
}
|
||||
|
||||
.u-edit__sens-select {
|
||||
width: 4.8rem;
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.u-edit__bio-container {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -102,7 +102,5 @@
|
|||
"weapon.category.STRINGERS": "ストリンガー",
|
||||
"weapon.category.SPLATANAS": "ワイパー",
|
||||
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
"theme.auto": "自動"
|
||||
"websiteSubtitle": "Competitive Splatoon Hub"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "過去の記事を見る"
|
||||
}
|
||||
|
|
@ -33,5 +33,7 @@
|
|||
"tag.name.LOW": "실력 제한",
|
||||
"tag.name.COUNT": "참가 제한",
|
||||
"tag.name.LAN": "현장 이벤트",
|
||||
"tag.name.QUALIFIER": "예선전"
|
||||
"tag.name.QUALIFIER": "예선전",
|
||||
|
||||
"websiteSubtitle": "Competitive Splatoon Hub"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"websiteSubtitle": "Competitive Splatoon Hub",
|
||||
"calendarGoTo": "달력에서 지나간 그리고 다가오는 이벤트들을 살펴보세요",
|
||||
"moreFeatures": "그 외 기능",
|
||||
"plus.description": "Plus Server 투표 기록 등 보기",
|
||||
"badges.description": "프로필에 얻을 수 있는 배지 목록",
|
||||
"recentWinners": "최근 우승자들",
|
||||
"upcomingEvents": "다가오는 이벤트들",
|
||||
"articleBy": "{{author}} 작성"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
}
|
||||
|
|
@ -104,9 +104,7 @@
|
|||
"weapon.category.STRINGERS": "Тетиваторы",
|
||||
"weapon.category.SPLATANAS": "Сплат-катаны",
|
||||
|
||||
"theme.light": "Светлая",
|
||||
"theme.dark": "Тёмная",
|
||||
"theme.auto": "Системная",
|
||||
"plans.poweredBy": "При поддержке {{name}}",
|
||||
|
||||
"plans.poweredBy": "При поддержке {{name}}"
|
||||
"websiteSubtitle": "Соревновательный Splatoon-хаб"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Посмотреть все прошлые статьи"
|
||||
}
|
||||
|
|
@ -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": "斯普拉遁竞技中心"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "查看全部文章"
|
||||
}
|
||||
BIN
public/static-assets/img/layout/articles.avif
Normal file
BIN
public/static-assets/img/layout/articles.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/articles.png
Normal file
BIN
public/static-assets/img/layout/articles.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
public/static-assets/img/layout/front-boy-bg.avif
Normal file
BIN
public/static-assets/img/layout/front-boy-bg.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/front-boy-bg.png
Normal file
BIN
public/static-assets/img/layout/front-boy-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
public/static-assets/img/layout/front-boy.avif
Normal file
BIN
public/static-assets/img/layout/front-boy.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/front-boy.png
Normal file
BIN
public/static-assets/img/layout/front-boy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
BIN
public/static-assets/img/layout/front-girl-bg.avif
Normal file
BIN
public/static-assets/img/layout/front-girl-bg.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/front-girl-bg.png
Normal file
BIN
public/static-assets/img/layout/front-girl-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
public/static-assets/img/layout/front-girl.avif
Normal file
BIN
public/static-assets/img/layout/front-girl.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/front-girl.png
Normal file
BIN
public/static-assets/img/layout/front-girl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 635 KiB |
|
|
@ -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>
|
||||
1
public/static-assets/svg/new-background-pattern.svg
Normal file
1
public/static-assets/svg/new-background-pattern.svg
Normal 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
353
scripts/hex-to-filter.ts
Normal 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());
|
||||
2
types/react-i18next.d.ts
vendored
2
types/react-i18next.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user