533 lines
20 KiB
TypeScript
533 lines
20 KiB
TypeScript
import Head from "next/head";
|
||
import { useRouter } from "next/router";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useSwipeable } from "react-swipeable";
|
||
import UAParser from "ua-parser-js";
|
||
import { useBoolean, useIsClient } from "usehooks-ts";
|
||
import Script from "next/script";
|
||
import { layout } from "../../design.config";
|
||
import { Ico, Icon } from "./Ico";
|
||
import { ButtonGroup } from "./Inputs/ButtonGroup";
|
||
import { OrderableList } from "./Inputs/OrderableList";
|
||
import { Select } from "./Inputs/Select";
|
||
import { TextInput } from "./Inputs/TextInput";
|
||
import { MainPanel } from "./Panels/MainPanel";
|
||
import { Popup } from "./Popup";
|
||
import { filterHasAttributes, isDefined, isUndefined } from "helpers/others";
|
||
import { prettyLanguage } from "helpers/formatters";
|
||
import { cIf, cJoin } from "helpers/className";
|
||
import { useAppLayout } from "contexts/AppLayoutContext";
|
||
import { Button } from "components/Inputs/Button";
|
||
import { OpenGraph, TITLE_PREFIX, TITLE_SEPARATOR } from "helpers/openGraph";
|
||
import { useIs1ColumnLayout, useIsScreenAtLeast } from "hooks/useContainerQuery";
|
||
import { useOnResize } from "hooks/useOnResize";
|
||
import { Ids } from "types/ids";
|
||
|
||
/*
|
||
* ╭─────────────╮
|
||
* ────────────────────────────────────────╯ CONSTANTS ╰──────────────────────────────────────────
|
||
*/
|
||
|
||
const SENSIBILITY_SWIPE = 1.1;
|
||
|
||
/*
|
||
* ╭─────────────╮
|
||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||
*/
|
||
|
||
export interface AppLayoutRequired {
|
||
openGraph: OpenGraph;
|
||
}
|
||
|
||
interface Props extends AppLayoutRequired {
|
||
subPanel?: React.ReactNode;
|
||
subPanelIcon?: Icon;
|
||
contentPanel?: React.ReactNode;
|
||
contentPanelScroolbar?: boolean;
|
||
}
|
||
|
||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||
|
||
export const AppLayout = ({
|
||
subPanel,
|
||
contentPanel,
|
||
openGraph,
|
||
subPanelIcon = Icon.Tune,
|
||
contentPanelScroolbar = true,
|
||
}: Props): JSX.Element => {
|
||
const {
|
||
configPanelOpen,
|
||
currency,
|
||
darkMode,
|
||
dyslexic,
|
||
fontSize,
|
||
mainPanelOpen,
|
||
mainPanelReduced,
|
||
menuGestures,
|
||
playerName,
|
||
preferredLanguages,
|
||
selectedThemeMode,
|
||
subPanelOpen,
|
||
setConfigPanelOpen,
|
||
setCurrency,
|
||
setDarkMode,
|
||
setDyslexic,
|
||
setFontSize,
|
||
setMainPanelOpen,
|
||
setPlayerName,
|
||
setPreferredLanguages,
|
||
setSelectedThemeMode,
|
||
setSubPanelOpen,
|
||
toggleMainPanelOpen,
|
||
toggleSubPanelOpen,
|
||
setScreenWidth,
|
||
setContentPanelWidth,
|
||
setSubPanelWidth,
|
||
langui,
|
||
currencies,
|
||
languages,
|
||
} = useAppLayout();
|
||
|
||
const router = useRouter();
|
||
const is1ColumnLayout = useIs1ColumnLayout();
|
||
const isScreenAtLeastXs = useIsScreenAtLeast("xs");
|
||
|
||
useOnResize(Ids.Body, (width) => setScreenWidth(width));
|
||
useOnResize(Ids.ContentPanel, (width) => setContentPanelWidth(width));
|
||
useOnResize(Ids.SubPanel, (width) => setSubPanelWidth(width));
|
||
|
||
const handlers = useSwipeable({
|
||
onSwipedLeft: (SwipeEventData) => {
|
||
if (menuGestures) {
|
||
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
||
if (mainPanelOpen) {
|
||
setMainPanelOpen(false);
|
||
} else if (isDefined(subPanel) && isDefined(contentPanel)) {
|
||
setSubPanelOpen(true);
|
||
}
|
||
}
|
||
},
|
||
onSwipedRight: (SwipeEventData) => {
|
||
if (menuGestures) {
|
||
if (SwipeEventData.velocity < SENSIBILITY_SWIPE) return;
|
||
if (subPanelOpen) {
|
||
setSubPanelOpen(false);
|
||
} else {
|
||
setMainPanelOpen(true);
|
||
}
|
||
}
|
||
},
|
||
});
|
||
|
||
const turnSubIntoContent = useMemo(
|
||
() => isDefined(subPanel) && isUndefined(contentPanel),
|
||
[contentPanel, subPanel]
|
||
);
|
||
|
||
const currencyOptions = useMemo(
|
||
() =>
|
||
filterHasAttributes(currencies, ["attributes"] as const).map(
|
||
(currentCurrency) => currentCurrency.attributes.code
|
||
),
|
||
[currencies]
|
||
);
|
||
|
||
const [currencySelect, setCurrencySelect] = useState<number>(-1);
|
||
|
||
useEffect(() => {
|
||
if (isDefined(currency)) setCurrencySelect(currencyOptions.indexOf(currency));
|
||
}, [currency, currencyOptions]);
|
||
|
||
useEffect(() => {
|
||
if (currencySelect >= 0) setCurrency(currencyOptions[currencySelect]);
|
||
}, [currencyOptions, currencySelect, setCurrency]);
|
||
|
||
const isClient = useIsClient();
|
||
const { value: hasDisgardSafariWarning, setTrue: disgardSafariWarning } = useBoolean(false);
|
||
const isSafari = useMemo<boolean>(() => {
|
||
if (isClient) {
|
||
const parser = new UAParser();
|
||
return parser.getBrowser().name === "Safari" || parser.getOS().name === "iOS";
|
||
}
|
||
return false;
|
||
}, [isClient]);
|
||
|
||
return (
|
||
<div
|
||
className={cJoin(
|
||
cIf(darkMode, "set-theme-dark", "set-theme-light"),
|
||
cIf(dyslexic, "set-theme-font-dyslexic", "set-theme-font-standard")
|
||
)}>
|
||
<div
|
||
{...handlers}
|
||
id={Ids.Body}
|
||
className={cJoin(
|
||
`fixed inset-0 m-0 grid touch-pan-y bg-light p-0 text-black
|
||
[grid-template-areas:'main_sub_content']`,
|
||
cIf(is1ColumnLayout, "grid-rows-[1fr_5rem] [grid-template-areas:'content''navbar']")
|
||
)}
|
||
style={{
|
||
gridTemplateColumns: is1ColumnLayout
|
||
? "1fr"
|
||
: `${mainPanelReduced ? layout.mainMenuReduced : layout.mainMenu}rem ${
|
||
isDefined(subPanel) ? layout.subMenu : 0
|
||
}rem 1fr`,
|
||
}}>
|
||
<Head>
|
||
<title>{openGraph.title}</title>
|
||
<meta name="description" content={openGraph.description} />
|
||
|
||
<meta name="twitter:title" content={openGraph.title} />
|
||
<meta name="twitter:description" content={openGraph.description} />
|
||
<meta name="twitter:card" content="summary_large_image" />
|
||
<meta name="twitter:image" content={openGraph.thumbnail.image} />
|
||
|
||
<meta property="og:title" content={openGraph.title} />
|
||
<meta property="og:description" content={openGraph.description} />
|
||
<meta property="og:image" content={openGraph.thumbnail.image} />
|
||
<meta property="og:image:secure_url" content={openGraph.thumbnail.image} />
|
||
<meta property="og:image:width" content={openGraph.thumbnail.width.toString()} />
|
||
<meta property="og:image:height" content={openGraph.thumbnail.height.toString()} />
|
||
<meta property="og:image:alt" content={openGraph.thumbnail.alt} />
|
||
<meta property="og:image:type" content="image/jpeg" />
|
||
</Head>
|
||
|
||
<Script
|
||
async
|
||
defer
|
||
data-website-id={process.env.NEXT_PUBLIC_UMAMI_ID}
|
||
src={`${process.env.NEXT_PUBLIC_UMAMI_URL}/umami.js`}
|
||
/>
|
||
|
||
{/* Background when navbar is opened */}
|
||
<div
|
||
className={cJoin(
|
||
`absolute inset-0 transition-[backdrop-filter] duration-500
|
||
[grid-area:content]`,
|
||
cIf(
|
||
(mainPanelOpen || subPanelOpen) && is1ColumnLayout,
|
||
"z-10 [backdrop-filter:blur(2px)]",
|
||
"pointer-events-none touch-none"
|
||
)
|
||
)}>
|
||
<div
|
||
className={cJoin(
|
||
"absolute inset-0 bg-shade transition-opacity duration-500",
|
||
cIf((mainPanelOpen || subPanelOpen) && is1ColumnLayout, "opacity-60", "opacity-0")
|
||
)}
|
||
onClick={() => {
|
||
setMainPanelOpen(false);
|
||
setSubPanelOpen(false);
|
||
}}></div>
|
||
</div>
|
||
|
||
{/* Content panel */}
|
||
<div
|
||
id={Ids.ContentPanel}
|
||
className={cJoin(
|
||
"texture-paper-dots bg-light [grid-area:content]",
|
||
cIf(contentPanelScroolbar, "overflow-y-scroll")
|
||
)}>
|
||
{isDefined(contentPanel) ? (
|
||
contentPanel
|
||
) : (
|
||
<ContentPlaceholder
|
||
message={langui.select_option_sidebar ?? ""}
|
||
icon={Icon.ChevronLeft}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Sub panel */}
|
||
{isDefined(subPanel) && (
|
||
<div
|
||
id={Ids.SubPanel}
|
||
className={cJoin(
|
||
`texture-paper-dots overflow-y-scroll border-r-[1px] border-dark/50 bg-light
|
||
transition-transform duration-300 [scrollbar-width:none]
|
||
webkit-scrollbar:w-0`,
|
||
cIf(
|
||
is1ColumnLayout,
|
||
`z-10 justify-self-end border-r-0
|
||
[grid-area:content]`,
|
||
"[grid-area:sub]"
|
||
),
|
||
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)] border-l-[1px]"),
|
||
cIf(is1ColumnLayout && !subPanelOpen && !turnSubIntoContent, "translate-x-[100vw]"),
|
||
cIf(is1ColumnLayout && turnSubIntoContent, "w-full border-l-0")
|
||
)}>
|
||
{subPanel}
|
||
</div>
|
||
)}
|
||
|
||
{/* Main panel */}
|
||
<div
|
||
className={cJoin(
|
||
`texture-paper-dots overflow-y-scroll border-r-[1px] border-dark/50 bg-light
|
||
transition-transform duration-300 [scrollbar-width:none] webkit-scrollbar:w-0`,
|
||
cIf(is1ColumnLayout, "z-10 justify-self-start [grid-area:content]", "[grid-area:main]"),
|
||
cIf(is1ColumnLayout && isScreenAtLeastXs, "w-[min(30rem,90%)]"),
|
||
cIf(!mainPanelOpen && is1ColumnLayout, "-translate-x-full")
|
||
)}>
|
||
<MainPanel />
|
||
</div>
|
||
|
||
{/* Navbar */}
|
||
<div
|
||
className={cJoin(
|
||
`texture-paper-dots grid grid-cols-[5rem_1fr_5rem] place-items-center
|
||
border-t-[1px] border-dotted border-black bg-light [grid-area:navbar]`,
|
||
cIf(!is1ColumnLayout, "hidden")
|
||
)}>
|
||
<Ico
|
||
icon={mainPanelOpen ? Icon.Close : Icon.Menu}
|
||
className="mt-[.1em] cursor-pointer !text-2xl"
|
||
onClick={() => {
|
||
toggleMainPanelOpen();
|
||
setSubPanelOpen(false);
|
||
}}
|
||
/>
|
||
<p
|
||
className={cJoin(
|
||
"overflow-hidden text-center font-headers font-black",
|
||
cIf(openGraph.title.length > 30, "max-h-14 text-xl", "max-h-16 text-2xl")
|
||
)}>
|
||
{openGraph.title.substring(TITLE_PREFIX.length + TITLE_SEPARATOR.length)
|
||
? openGraph.title.substring(TITLE_PREFIX.length + TITLE_SEPARATOR.length)
|
||
: "Accord’s Library"}
|
||
</p>
|
||
{isDefined(subPanel) && !turnSubIntoContent && (
|
||
<Ico
|
||
icon={subPanelOpen ? Icon.Close : subPanelIcon}
|
||
className="mt-[.1em] cursor-pointer !text-2xl"
|
||
onClick={() => {
|
||
toggleSubPanelOpen();
|
||
setMainPanelOpen(false);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<Popup state={isSafari && !hasDisgardSafariWarning} onClose={() => null}>
|
||
<h1 className="text-2xl">Hi, you are using Safari!</h1>
|
||
<p className="max-w-lg text-center">
|
||
In most cases this wouldn’t be a problem but our website is—for some obscure
|
||
reason—performing terribly on Safari (WebKit). Because of that, we have decided to
|
||
display this message instead of letting you have a slow and painful experience. We are
|
||
looking into the problem, and are hoping to fix this soon.
|
||
</p>
|
||
<p>In the meanwhile, if you are using an iPhone/iPad, please try using another device.</p>
|
||
<p>If you are on macOS, please use another browser such as Firefox or Chrome.</p>
|
||
|
||
<Button
|
||
text="Let me in regardless"
|
||
className="mt-8"
|
||
onClick={() => {
|
||
disgardSafariWarning();
|
||
umami("[Safari] Disgard warning");
|
||
}}
|
||
/>
|
||
</Popup>
|
||
|
||
<Popup
|
||
state={configPanelOpen}
|
||
onClose={() => {
|
||
setConfigPanelOpen(false);
|
||
umami("[Settings] Close settings");
|
||
}}>
|
||
<h2 className="text-2xl">{langui.settings}</h2>
|
||
|
||
<div
|
||
className={cJoin(
|
||
`mt-4 grid justify-items-center gap-16 text-center`,
|
||
cIf(!is1ColumnLayout, "grid-cols-[auto_auto]")
|
||
)}>
|
||
{router.locales && (
|
||
<div>
|
||
<h3 className="text-xl">{langui.languages}</h3>
|
||
{preferredLanguages.length > 0 && (
|
||
<OrderableList
|
||
items={preferredLanguages.map((locale) => ({
|
||
code: locale,
|
||
name: prettyLanguage(locale, languages),
|
||
}))}
|
||
insertLabels={[
|
||
{
|
||
insertAt: 0,
|
||
name: langui.primary_language ?? "Primary language",
|
||
},
|
||
{
|
||
insertAt: 1,
|
||
name: langui.secondary_language ?? "Secondary languages",
|
||
},
|
||
]}
|
||
onChange={(items) => {
|
||
const newPreferredLanguages = items.map((item) => item.code);
|
||
setPreferredLanguages(newPreferredLanguages);
|
||
umami("[Settings] Change preferred languages");
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div
|
||
className={cJoin(
|
||
"grid place-items-center gap-8 text-center",
|
||
cIf(!is1ColumnLayout, "grid-cols-2")
|
||
)}>
|
||
<div>
|
||
<h3 className="text-xl">{langui.theme}</h3>
|
||
<ButtonGroup
|
||
buttonsProps={[
|
||
{
|
||
onClick: () => {
|
||
setDarkMode(false);
|
||
setSelectedThemeMode(true);
|
||
umami("[Settings] Change theme (light)");
|
||
},
|
||
active: selectedThemeMode && !darkMode,
|
||
text: langui.light,
|
||
},
|
||
{
|
||
onClick: () => {
|
||
setSelectedThemeMode(false);
|
||
umami("[Settings] Change theme (auto)");
|
||
},
|
||
active: !selectedThemeMode,
|
||
text: langui.auto,
|
||
},
|
||
{
|
||
onClick: () => {
|
||
setDarkMode(true);
|
||
setSelectedThemeMode(true);
|
||
umami("[Settings] Change theme (dark)");
|
||
},
|
||
active: selectedThemeMode && darkMode,
|
||
text: langui.dark,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="text-xl">{langui.currency}</h3>
|
||
<div>
|
||
<Select
|
||
options={currencyOptions}
|
||
value={currencySelect}
|
||
onChange={(newCurrency) => {
|
||
setCurrencySelect(newCurrency);
|
||
umami(`[Settings] Change currency (${currencyOptions[newCurrency]})}`);
|
||
}}
|
||
className="w-28"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="text-xl">{langui.font_size}</h3>
|
||
<ButtonGroup
|
||
buttonsProps={[
|
||
{
|
||
onClick: () => {
|
||
setFontSize((current) => current / 1.05);
|
||
umami(
|
||
`[Settings] Change font size (${((fontSize / 1.05) * 100).toLocaleString(
|
||
undefined,
|
||
{
|
||
maximumFractionDigits: 0,
|
||
}
|
||
)}%)`
|
||
);
|
||
},
|
||
icon: Icon.TextDecrease,
|
||
},
|
||
{
|
||
onClick: () => {
|
||
setFontSize(1);
|
||
umami("[Settings] Change font size (100%)");
|
||
},
|
||
text: `${(fontSize * 100).toLocaleString(undefined, {
|
||
maximumFractionDigits: 0,
|
||
})}%`,
|
||
},
|
||
{
|
||
onClick: () => {
|
||
setFontSize((current) => current * 1.05);
|
||
umami(
|
||
`[Settings] Change font size (${(fontSize * 1.05 * 100).toLocaleString(
|
||
undefined,
|
||
{
|
||
maximumFractionDigits: 0,
|
||
}
|
||
)}%)`
|
||
);
|
||
},
|
||
icon: Icon.TextIncrease,
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="text-xl">{langui.font}</h3>
|
||
<div className="grid gap-2">
|
||
<Button
|
||
active={!dyslexic}
|
||
onClick={() => {
|
||
setDyslexic(false);
|
||
umami("[Settings] Change font (Zen Maru Gothic)");
|
||
}}
|
||
className="font-zenMaruGothic"
|
||
text="Zen Maru Gothic"
|
||
/>
|
||
<Button
|
||
active={dyslexic}
|
||
onClick={() => {
|
||
setDyslexic(true);
|
||
umami("[Settings] Change font (OpenDyslexic)");
|
||
}}
|
||
className="font-openDyslexic"
|
||
text="OpenDyslexic"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="text-xl">{langui.player_name}</h3>
|
||
<TextInput
|
||
placeholder="<player>"
|
||
className="w-48"
|
||
value={playerName}
|
||
onChange={(newName) => {
|
||
setPlayerName(newName);
|
||
umami("[Settings] Change username");
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||
|
||
interface ContentPlaceholderProps {
|
||
message: string;
|
||
icon?: Icon;
|
||
}
|
||
|
||
const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Element => (
|
||
<div className="grid h-full place-content-center">
|
||
<div
|
||
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
|
||
border-dark p-8 text-dark opacity-40">
|
||
{isDefined(icon) && <Ico icon={icon} className="!text-[300%]" />}
|
||
<p className={cJoin("w-64 text-2xl", cIf(!isDefined(icon), "text-center"))}>{message}</p>
|
||
</div>
|
||
</div>
|
||
);
|