Added meilisearch (#89)

* Added search on most pages

* Changed material icons to symbols and added wikipage search

* Updated deps

* Changed color of the play button on previewcard overlay

* Updated search params

* Updated deps

* Audit fix

* Removed unused graphql files
This commit is contained in:
DrMint 2023-01-07 01:59:54 +01:00 committed by GitHub
parent 0ddd46643b
commit dd3beff508
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 12375 additions and 4581 deletions

View File

@ -1,4 +1,5 @@
src/graphql/generated.ts src/graphql/generated.ts
src/shared
.eslintrc.js .eslintrc.js
graphql-codegen.config.js graphql-codegen.config.js
next-env.d.ts next-env.d.ts

4
.ncurc.json Normal file
View File

@ -0,0 +1,4 @@
{
"upgrade": false,
"reject": ["react-hotkeys-hook"]
}

View File

@ -34,7 +34,7 @@
- Support for arbitrary React Components and Component Props! - Support for arbitrary React Components and Component Props!
- Autogenerated multi-level table of content and anchor links for the different headers - Autogenerated multi-level table of content and anchor links for the different headers
- Styling: [Tailwind CSS](https://tailwindcss.com/) - Styling: [Tailwind CSS](https://tailwindcss.com/)
- Support for [Material Icons](https://fonts.google.com/icons) - Support for [Material Symbols](https://fonts.google.com/icons)
- Support for creating any arbitrary theming mode by swapping CSS variables - Support for creating any arbitrary theming mode by swapping CSS variables
- Support for Container Queries (media queries at the element level) - Support for Container Queries (media queries at the element level)
- The website has a three-column layout, which turns into one-column + 2 toggleable side-menus if the screen is too narrow. - The website has a three-column layout, which turns into one-column + 2 toggleable side-menus if the screen is too narrow.

3495
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,8 +17,6 @@
"prettier": "prettier --end-of-line auto --write ." "prettier": "prettier --end-of-line auto --write ."
}, },
"dependencies": { "dependencies": {
"@fontsource/material-icons": "^4.5.4",
"@fontsource/material-icons-outlined": "^4.5.4",
"@fontsource/opendyslexic": "^4.5.4", "@fontsource/opendyslexic": "^4.5.4",
"@fontsource/share-tech-mono": "^4.5.9", "@fontsource/share-tech-mono": "^4.5.9",
"@fontsource/vollkorn": "^4.5.12", "@fontsource/vollkorn": "^4.5.12",
@ -26,10 +24,13 @@
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"cuid": "^2.1.8", "cuid": "^2.1.8",
"graphql-request": "^5.0.0", "isomorphic-dompurify": "^0.25.0",
"jotai": "^1.11.0", "jotai": "^1.12.1",
"markdown-to-jsx": "^7.1.7", "markdown-to-jsx": "^7.1.8",
"next": "^13.0.6", "marked": "^4.2.5",
"material-symbols": "^0.4.2",
"meilisearch": "^0.30.0",
"next": "^13.1.1",
"nodemailer": "^6.8.0", "nodemailer": "^6.8.0",
"rc-slider": "^10.1.0", "rc-slider": "^10.1.0",
"react": "18.2.0", "react": "18.2.0",
@ -42,35 +43,38 @@
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.1.1", "turndown": "^7.1.1",
"ua-parser-js": "^1.0.32", "ua-parser-js": "^1.0.32",
"usehooks-ts": "^2.9.1" "usehooks-ts": "^2.9.1",
"zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@digitak/esrun": "^3.2.15", "@digitak/esrun": "^3.2.15",
"@graphql-codegen/cli": "^2.15.0", "@graphql-codegen/cli": "^2.16.3",
"@graphql-codegen/typescript": "2.8.3", "@graphql-codegen/typescript": "2.8.7",
"@graphql-codegen/typescript-graphql-request": "^4.5.8", "@graphql-codegen/typescript-graphql-request": "^4.5.8",
"@graphql-codegen/typescript-operations": "^2.5.8", "@graphql-codegen/typescript-operations": "^2.5.12",
"@types/node": "18.11.10", "@types/marked": "^4.0.8",
"@types/nodemailer": "^6.4.6", "@types/node": "18.11.18",
"@types/react": "^18.0.22", "@types/nodemailer": "^6.4.7",
"@types/react-dom": "^18.0.9", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/string-natural-compare": "^3.0.2", "@types/string-natural-compare": "^3.0.2",
"@types/throttle-debounce": "^5.0.0", "@types/throttle-debounce": "^5.0.0",
"@types/turndown": "^5.0.1", "@types/turndown": "^5.0.1",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/eslint-plugin": "^5.48.0",
"@typescript-eslint/parser": "^5.45.0", "@typescript-eslint/parser": "^5.48.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "^8.29.0", "eslint": "^8.31.0",
"eslint-config-next": "13.0.6", "eslint-config-next": "13.1.1",
"eslint-plugin-import": "^2.26.0", "eslint-plugin-import": "^2.26.0",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"next-sitemap": "^3.1.32", "graphql-request": "^5.1.0",
"prettier": "^2.8.0", "next-sitemap": "^3.1.44",
"prettier-plugin-tailwindcss": "^0.2.0", "prettier": "^2.8.1",
"prettier-plugin-tailwindcss": "^0.2.1",
"tailwindcss": "^3.2.4", "tailwindcss": "^3.2.4",
"ts-unused-exports": "^8.0.0", "ts-unused-exports": "^9.0.1",
"typescript": "^4.9.3" "typescript": "^4.9.4"
}, },
"overrides": { "overrides": {
"react-zoom-pan-pinch": { "react-zoom-pan-pinch": {

View File

@ -185,7 +185,15 @@
"double_page_view": "Double page view", "double_page_view": "Double page view",
"reset_all_options": "Reset all options", "reset_all_options": "Reset all options",
"reading_layout": "Reading layout", "reading_layout": "Reading layout",
"quality": "Quality" "quality": "Quality",
"only_unavailable_videos": "Only unavailable videos",
"oldest": "Oldest",
"newest": "Newest",
"least_popular": "Least popular",
"most_popular": "Most popular",
"shortest": "Shortest",
"longest": "Longest",
"search": "Search"
} }
}, },
{ {
@ -372,7 +380,15 @@
"double_page_view": "Vue 2 pages", "double_page_view": "Vue 2 pages",
"reset_all_options": "Réinitialiser les options", "reset_all_options": "Réinitialiser les options",
"reading_layout": "Mode de lecture", "reading_layout": "Mode de lecture",
"quality": "Qualité" "quality": "Qualité",
"only_unavailable_videos": "Seulement les vidéos indisponibles",
"oldest": "Plus anciennes",
"newest": "Plus récentes",
"least_popular": "Plus populaires",
"most_popular": "Moins populaires",
"shortest": "Plus courtes",
"longest": "Plus longues",
"search": "Rechercher"
} }
}, },
{ {
@ -559,7 +575,15 @@
"double_page_view": null, "double_page_view": null,
"reset_all_options": null, "reset_all_options": null,
"reading_layout": null, "reading_layout": null,
"quality": null "quality": null,
"only_unavailable_videos": null,
"oldest": null,
"newest": null,
"least_popular": null,
"most_popular": null,
"shortest": null,
"longest": null,
"search": null
} }
}, },
{ {
@ -746,7 +770,15 @@
"double_page_view": null, "double_page_view": null,
"reset_all_options": null, "reset_all_options": null,
"reading_layout": null, "reading_layout": null,
"quality": null "quality": null,
"only_unavailable_videos": null,
"oldest": null,
"newest": null,
"least_popular": null,
"most_popular": null,
"shortest": null,
"longest": null,
"search": null
} }
}, },
{ {
@ -933,7 +965,15 @@
"double_page_view": null, "double_page_view": null,
"reset_all_options": null, "reset_all_options": null,
"reading_layout": null, "reading_layout": null,
"quality": null "quality": null,
"only_unavailable_videos": null,
"oldest": null,
"newest": null,
"least_popular": null,
"most_popular": null,
"shortest": null,
"longest": null,
"search": null
} }
} }
] ]

View File

@ -1,4 +1,4 @@
import { Ico, Icon } from "./Ico"; import { Ico } from "./Ico";
import { ToolTip } from "./ToolTip"; import { ToolTip } from "./ToolTip";
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
@ -22,8 +22,8 @@ export const AnchorShare = ({ id, className }: Props): JSX.Element => {
<ToolTip content={langui.copy_anchor_link} trigger="mouseenter" className="text-sm"> <ToolTip content={langui.copy_anchor_link} trigger="mouseenter" className="text-sm">
<ToolTip content={langui.anchor_link_copied} trigger="click" className="text-sm"> <ToolTip content={langui.anchor_link_copied} trigger="click" className="text-sm">
<Ico <Ico
icon={Icon.Link} icon="link"
className={cJoin("transition-color cursor-pointer hover:text-dark", className)} className={cJoin("cursor-pointer transition-colors hover:text-dark", className)}
onClick={() => { onClick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`${process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname}#${id}` `${process.env.NEXT_PUBLIC_URL_SELF + window.location.pathname}#${id}`

View File

@ -1,7 +1,8 @@
import Head from "next/head"; import Head from "next/head";
import { useSwipeable } from "react-swipeable"; import { useSwipeable } from "react-swipeable";
import { MaterialSymbol } from "material-symbols";
import { layout } from "../../design.config"; import { layout } from "../../design.config";
import { Ico, Icon } from "./Ico"; import { Ico } from "./Ico";
import { MainPanel } from "./Panels/MainPanel"; import { MainPanel } from "./Panels/MainPanel";
import { isDefined, isUndefined } from "helpers/asserts"; import { isDefined, isUndefined } from "helpers/asserts";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
@ -28,7 +29,7 @@ export interface AppLayoutRequired {
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {
subPanel?: React.ReactNode; subPanel?: React.ReactNode;
subPanelIcon?: Icon; subPanelIcon?: MaterialSymbol;
contentPanel?: React.ReactNode; contentPanel?: React.ReactNode;
contentPanelScroolbar?: boolean; contentPanelScroolbar?: boolean;
} }
@ -39,7 +40,7 @@ export const AppLayout = ({
subPanel, subPanel,
contentPanel, contentPanel,
openGraph, openGraph,
subPanelIcon = Icon.Tune, subPanelIcon = "tune",
contentPanelScroolbar = true, contentPanelScroolbar = true,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const isMainPanelReduced = useAtomGetter(atoms.layout.mainPanelReduced); const isMainPanelReduced = useAtomGetter(atoms.layout.mainPanelReduced);
@ -148,10 +149,7 @@ export const AppLayout = ({
{isDefined(contentPanel) ? ( {isDefined(contentPanel) ? (
contentPanel contentPanel
) : ( ) : (
<ContentPlaceholder <ContentPlaceholder message={langui.select_option_sidebar ?? ""} icon={"chevron_left"} />
message={langui.select_option_sidebar ?? ""}
icon={Icon.ChevronLeft}
/>
)} )}
</div> </div>
@ -195,7 +193,7 @@ export const AppLayout = ({
cIf(!is1ColumnLayout, "hidden") cIf(!is1ColumnLayout, "hidden")
)}> )}>
<Ico <Ico
icon={isMainPanelOpened ? Icon.Close : Icon.Menu} icon={isMainPanelOpened ? "close" : "menu"}
className="cursor-pointer !text-2xl" className="cursor-pointer !text-2xl"
onClick={() => { onClick={() => {
setMainPanelOpened((current) => !current); setMainPanelOpened((current) => !current);
@ -213,7 +211,7 @@ export const AppLayout = ({
</p> </p>
{isDefined(subPanel) && !turnSubIntoContent && ( {isDefined(subPanel) && !turnSubIntoContent && (
<Ico <Ico
icon={isSubPanelOpened ? Icon.Close : subPanelIcon} icon={isSubPanelOpened ? "close" : subPanelIcon}
className="cursor-pointer !text-2xl" className="cursor-pointer !text-2xl"
onClick={() => { onClick={() => {
setSubPanelOpened((current) => !current); setSubPanelOpened((current) => !current);
@ -230,7 +228,7 @@ export const AppLayout = ({
interface ContentPlaceholderProps { interface ContentPlaceholderProps {
message: string; message: string;
icon?: Icon; icon?: MaterialSymbol;
} }
const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Element => ( const ContentPlaceholder = ({ message, icon }: ContentPlaceholderProps): JSX.Element => (

View File

@ -4,7 +4,7 @@ import { TranslatedChroniclePreview } from "./ChroniclePreview";
import { GetChroniclesChaptersQuery } from "graphql/generated"; import { GetChroniclesChaptersQuery } from "graphql/generated";
import { filterHasAttributes } from "helpers/asserts"; import { filterHasAttributes } from "helpers/asserts";
import { prettyInlineTitle, prettySlug, sJoin } from "helpers/formatters"; import { prettyInlineTitle, prettySlug, sJoin } from "helpers/formatters";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { compareDate } from "helpers/date"; import { compareDate } from "helpers/date";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
@ -33,7 +33,7 @@ const ChroniclesList = ({ chronicles, currentSlug, title }: Props): JSX.Element
<div> <div>
<div className="grid place-content-center"> <div className="grid place-content-center">
<div className="grid cursor-pointer grid-cols-[1em_1fr] gap-4" onClick={toggleOpen}> <div className="grid cursor-pointer grid-cols-[1em_1fr] gap-4" onClick={toggleOpen}>
<Ico className="!text-xl" icon={isOpen ? Icon.ArrowDropUp : Icon.ArrowDropDown} /> <Ico className="!text-xl" icon={isOpen ? "arrow_drop_up" : "arrow_drop_down"} />
<p className="mb-4 font-headers text-xl">{title}</p> <p className="mb-4 font-headers text-xl">{title}</p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,63 @@
import { Ico } from "components/Ico";
import { PageSelector } from "components/Inputs/PageSelector";
import { atoms } from "contexts/atoms";
import { isUndefined } from "helpers/asserts";
import { useAtomGetter } from "helpers/atoms";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { Ids } from "types/ids";
interface Props {
page: number;
onPageChange: (newPage: number) => void;
totalNumberOfPages: number | null | undefined;
children: React.ReactNode;
}
export const Paginator = ({
page,
onPageChange,
totalNumberOfPages,
children,
}: Props): JSX.Element => {
useScrollTopOnChange(Ids.ContentPanel, [page]);
if (totalNumberOfPages === 0) return <DefaultRenderWhenEmpty />;
if (isUndefined(totalNumberOfPages) || totalNumberOfPages < 2) return <>{children}</>;
return (
<>
<PageSelector
page={page}
onChange={onPageChange}
pagesCount={totalNumberOfPages}
className="mb-12"
/>
{children}
<PageSelector
page={page}
onChange={onPageChange}
pagesCount={totalNumberOfPages}
className="mt-12"
/>
</>
);
};
/*
*
* PRIVATE COMPONENTS
*/
const DefaultRenderWhenEmpty = () => {
const is3ColumnsLayout = useAtomGetter(atoms.containerQueries.is3ColumnsLayout);
const langui = useAtomGetter(atoms.localData.langui);
return (
<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">
{is3ColumnsLayout && <Ico icon="chevron_left" className="!text-[300%]" />}
<p className="max-w-xs text-2xl">{langui.no_results_message}</p>
{!is3ColumnsLayout && <Ico icon="chevron_right" className="!text-[300%]" />}
</div>
</div>
);
};

View File

@ -3,6 +3,7 @@ import { useHotkeys } from "react-hotkeys-hook";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomSetter } from "helpers/atoms"; import { useAtomSetter } from "helpers/atoms";
import { Button } from "components/Inputs/Button";
/* /*
* *
@ -16,6 +17,7 @@ interface Props {
fillViewport?: boolean; fillViewport?: boolean;
hideBackground?: boolean; hideBackground?: boolean;
padding?: boolean; padding?: boolean;
withCloseButton?: boolean;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
@ -27,10 +29,11 @@ export const Popup = ({
fillViewport, fillViewport,
hideBackground = false, hideBackground = false,
padding = true, padding = true,
withCloseButton = true,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled); const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled);
useHotkeys("escape", () => onCloseRequest?.(), {}, [onCloseRequest]); useHotkeys("escape", () => onCloseRequest?.(), { enabled: isVisible }, [onCloseRequest]);
useEffect(() => { useEffect(() => {
setMenuGesturesEnabled(!isVisible); setMenuGesturesEnabled(!isVisible);
@ -55,9 +58,18 @@ export const Popup = ({
"grid place-items-center gap-4 transition-transform", "grid place-items-center gap-4 transition-transform",
cIf(padding, "p-10"), cIf(padding, "p-10"),
cIf(isVisible, "scale-100", "scale-0"), cIf(isVisible, "scale-100", "scale-0"),
cIf(fillViewport, "absolute inset-10", "relative max-h-[80vh] overflow-y-auto"), cIf(
fillViewport,
"absolute inset-10 content-start overflow-scroll",
"relative max-h-[80vh] overflow-y-auto"
),
cIf(!hideBackground, "rounded-lg bg-light shadow-2xl shadow-shade") cIf(!hideBackground, "rounded-lg bg-light shadow-2xl shadow-shade")
)}> )}>
{withCloseButton && (
<div className="absolute right-6 top-6">
<Button icon="close" onClick={onCloseRequest} />
</div>
)}
{children} {children}
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import { MouseEventHandler, useCallback } from "react"; import { MouseEventHandler, useCallback } from "react";
import { MaterialSymbol } from "material-symbols";
import { Link } from "./Link"; import { Link } from "./Link";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts"; import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
@ -16,7 +17,7 @@ interface Props {
className?: string; className?: string;
href?: string; href?: string;
active?: boolean; active?: boolean;
icon?: Icon; icon?: MaterialSymbol;
text?: string | null | undefined; text?: string | null | undefined;
alwaysNewTab?: boolean; alwaysNewTab?: boolean;
onClick?: MouseEventHandler<HTMLDivElement>; onClick?: MouseEventHandler<HTMLDivElement>;
@ -34,7 +35,7 @@ export const Button = ({
id, id,
onClick, onClick,
onMouseUp, onMouseUp,
active, active = false,
className, className,
icon, icon,
text, text,
@ -81,7 +82,13 @@ export const Button = ({
</div> </div>
)} )}
{isDefinedAndNotEmpty(icon) && ( {isDefinedAndNotEmpty(icon) && (
<Ico className="[font-size:150%] [line-height:0.66]" icon={icon} /> <Ico
className="[font-size:150%] [line-height:0.66]"
icon={icon}
isFilled={active}
opticalSize={size === "normal" ? 24 : 20}
weight={size === "normal" ? 500 : 800}
/>
)} )}
{isDefinedAndNotEmpty(text) && <p className="-translate-y-[0.05em] text-center">{text}</p>} {isDefinedAndNotEmpty(text) && <p className="-translate-y-[0.05em] text-center">{text}</p>}
</div> </div>

View File

@ -1,7 +1,6 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { ToolTip } from "../ToolTip"; import { ToolTip } from "../ToolTip";
import { Button } from "./Button"; import { Button } from "./Button";
import { Icon } from "components/Ico";
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
import { prettyLanguage } from "helpers/formatters"; import { prettyLanguage } from "helpers/formatters";
import { iterateMap } from "helpers/others"; import { iterateMap } from "helpers/others";
@ -54,7 +53,7 @@ export const LanguageSwitcher = ({
}> }>
<Button <Button
badgeNumber={showBadge && locales.size > 1 ? locales.size : undefined} badgeNumber={showBadge && locales.size > 1 ? locales.size : undefined}
icon={Icon.Translate} icon="translate"
size={size} size={size}
/> />
</ToolTip> </ToolTip>

View File

@ -1,5 +1,5 @@
import { Fragment, useCallback } from "react"; import { Fragment, useCallback } from "react";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { arrayMove } from "helpers/others"; import { arrayMove } from "helpers/others";
import { isDefinedAndNotEmpty } from "helpers/asserts"; import { isDefinedAndNotEmpty } from "helpers/asserts";
@ -71,7 +71,7 @@ export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Ele
<div className="grid grid-rows-[.8em_.8em] place-items-center"> <div className="grid grid-rows-[.8em_.8em] place-items-center">
{index > 0 && ( {index > 0 && (
<Ico <Ico
icon={Icon.ArrowDropUp} icon="arrow_drop_up"
className="row-start-1 cursor-pointer" className="row-start-1 cursor-pointer"
onClick={() => { onClick={() => {
updateOrder(index, index - 1); updateOrder(index, index - 1);
@ -80,7 +80,7 @@ export const OrderableList = ({ onChange, items, insertLabels }: Props): JSX.Ele
)} )}
{index < items.length - 1 && ( {index < items.length - 1 && (
<Ico <Ico
icon={Icon.ArrowDropDown} icon="arrow_drop_down"
className="row-start-2 cursor-pointer" className="row-start-2 cursor-pointer"
onClick={() => { onClick={() => {
updateOrder(index, index + 1); updateOrder(index, index + 1);

View File

@ -1,5 +1,4 @@
import { ButtonGroup } from "./ButtonGroup"; import { ButtonGroup } from "./ButtonGroup";
import { Icon } from "components/Ico";
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
/* /*
@ -21,25 +20,25 @@ export const PageSelector = ({ page, className, pagesCount, onChange }: Props):
className={cJoin("flex flex-row place-content-center", className)} className={cJoin("flex flex-row place-content-center", className)}
buttonsProps={[ buttonsProps={[
{ {
onClick: () => onChange(0), onClick: () => onChange(1),
disabled: page === 0, disabled: page === 1,
icon: Icon.FirstPage, icon: "first_page",
}, },
{ {
onClick: () => page > 0 && onChange(page - 1), onClick: () => page > 1 && onChange(page - 1),
disabled: page === 0, disabled: page === 1,
icon: Icon.NavigateBefore, icon: "navigate_before",
}, },
{ text: `${page + 1} / ${pagesCount}` }, { text: `${page} / ${pagesCount}` },
{ {
onClick: () => page < pagesCount - 1 && onChange(page + 1), onClick: () => page < pagesCount && onChange(page + 1),
disabled: page === pagesCount - 1, disabled: page === pagesCount,
icon: Icon.NavigateNext, icon: "navigate_next",
}, },
{ {
onClick: () => onChange(pagesCount - 1), onClick: () => onChange(pagesCount),
disabled: page === pagesCount - 1, disabled: page === pagesCount,
icon: Icon.LastPage, icon: "last_page",
}, },
]} ]}
/> />

View File

@ -1,6 +1,6 @@
import { Fragment, useCallback, useRef } from "react"; import { Fragment, useCallback, useRef } from "react";
import { useBoolean, useOnClickOutside } from "usehooks-ts"; import { useBoolean, useOnClickOutside } from "usehooks-ts";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
/* /*
@ -72,12 +72,12 @@ export const Select = ({
</p> </p>
{value >= 0 && allowEmpty && ( {value >= 0 && allowEmpty && (
<Ico <Ico
icon={Icon.Close} icon="close"
className="!text-xs" className="!text-xs"
onClick={() => !disabled && onSelectionChanged(-1)} onClick={() => !disabled && onSelectionChanged(-1)}
/> />
)} )}
<Ico onClick={tryToggling} icon={isOpened ? Icon.ArrowDropUp : Icon.ArrowDropDown} /> <Ico onClick={tryToggling} icon={isOpened ? "arrow_drop_up" : "arrow_drop_down"} />
</div> </div>
<div className={cJoin("left-0 right-0 rounded-b-[1em]", cIf(isOpened, "absolute", "hidden"))}> <div className={cJoin("left-0 right-0 rounded-b-[1em]", cIf(isOpened, "absolute", "hidden"))}>
{options.map((option, index) => ( {options.map((option, index) => (

View File

@ -1,4 +1,4 @@
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { isDefinedAndNotEmpty } from "helpers/asserts"; import { isDefinedAndNotEmpty } from "helpers/asserts";
@ -12,7 +12,7 @@ interface Props {
onChange: (newValue: string) => void; onChange: (newValue: string) => void;
className?: string; className?: string;
name?: string; name?: string;
placeholder?: string; placeholder?: string | null;
disabled?: boolean; disabled?: boolean;
} }
@ -33,7 +33,7 @@ export const TextInput = ({
name={name} name={name}
value={value} value={value}
disabled={disabled} disabled={disabled}
placeholder={placeholder} placeholder={placeholder ?? undefined}
onChange={(event) => { onChange={(event) => {
onChange(event.target.value); onChange(event.target.value);
}} }}
@ -42,7 +42,7 @@ export const TextInput = ({
<div className="absolute right-4 top-0 bottom-0 grid place-items-center"> <div className="absolute right-4 top-0 bottom-0 grid place-items-center">
<Ico <Ico
className={cJoin("!text-xs", cIf(disabled, "opacity-30 grayscale", "cursor-pointer"))} className={cJoin("!text-xs", cIf(disabled, "opacity-30 grayscale", "cursor-pointer"))}
icon={Icon.Close} icon="close"
onClick={() => !disabled && onChange("")} onClick={() => !disabled && onChange("")}
/> />
</div> </div>

View File

@ -1,4 +1,3 @@
import { Icon } from "components/Ico";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { ToolTip } from "components/ToolTip"; import { ToolTip } from "components/ToolTip";
import { LibraryItemUserStatus } from "types/types"; import { LibraryItemUserStatus } from "types/types";
@ -31,7 +30,7 @@ export const PreviewCardCTAs = ({ id, expand = false }: Props): JSX.Element => {
)}> )}>
<ToolTip content={langui.want_it} disabled={expand}> <ToolTip content={langui.want_it} disabled={expand}>
<Button <Button
icon={Icon.Favorite} icon="favorite"
text={expand ? langui.want_it : undefined} text={expand ? langui.want_it : undefined}
active={libraryItemUserStatus[id] === LibraryItemUserStatus.Want} active={libraryItemUserStatus[id] === LibraryItemUserStatus.Want}
onClick={(event) => { onClick={(event) => {
@ -49,7 +48,7 @@ export const PreviewCardCTAs = ({ id, expand = false }: Props): JSX.Element => {
</ToolTip> </ToolTip>
<ToolTip content={langui.have_it} disabled={expand}> <ToolTip content={langui.have_it} disabled={expand}>
<Button <Button
icon={Icon.BackHand} icon="back_hand"
text={expand ? langui.have_it : undefined} text={expand ? langui.have_it : undefined}
active={libraryItemUserStatus[id] === LibraryItemUserStatus.Have} active={libraryItemUserStatus[id] === LibraryItemUserStatus.Have}
onClick={(event) => { onClick={(event) => {

View File

@ -3,7 +3,6 @@ import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import { Img } from "./Img"; import { Img } from "./Img";
import { Button } from "./Inputs/Button"; import { Button } from "./Inputs/Button";
import { Icon } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { useFullscreen } from "hooks/useFullscreen"; import { useFullscreen } from "hooks/useFullscreen";
import { Ids } from "types/ids"; import { Ids } from "types/ids";
@ -142,24 +141,17 @@ const ControlButtons = ({
const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout); const is1ColumnLayout = useAtomGetter(atoms.containerQueries.is1ColumnLayout);
const PreviousButton = () => ( const PreviousButton = () => (
<Button <Button icon="navigate_before" onClick={onPressPrevious} disabled={!isPreviousImageAvailable} />
icon={Icon.NavigateBefore}
onClick={onPressPrevious}
disabled={!isPreviousImageAvailable}
/>
); );
const NextButton = () => ( const NextButton = () => (
<Button icon={Icon.NavigateNext} onClick={onPressNext} disabled={!isNextImageAvailable} /> <Button icon="navigate_next" onClick={onPressNext} disabled={!isNextImageAvailable} />
); );
const FullscreenButton = () => ( const FullscreenButton = () => (
<Button <Button icon={isFullscreen ? "fullscreen_exit" : "fullscreen"} onClick={toggleFullscreen} />
icon={isFullscreen ? Icon.FullscreenExit : Icon.Fullscreen}
onClick={toggleFullscreen}
/>
); );
const CloseButton = () => <Button onClick={onCloseRequest} icon={Icon.Close} />; const CloseButton = () => <Button onClick={onCloseRequest} icon="close" />;
return is1ColumnLayout ? ( return is1ColumnLayout ? (
<> <>

View File

@ -10,7 +10,7 @@ import { getAssetURL, ImageQuality } from "helpers/img";
import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts"; import { isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts";
import { AnchorShare } from "components/AnchorShare"; import { AnchorShare } from "components/AnchorShare";
import { useIntersectionList } from "hooks/useIntersectionList"; import { useIntersectionList } from "hooks/useIntersectionList";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
@ -268,9 +268,9 @@ const Header = ({ level, title, slug }: HeaderProps): JSX.Element => {
<div className="ml-10 flex place-items-center gap-4"> <div className="ml-10 flex place-items-center gap-4">
{title === "* * *" ? ( {title === "* * *" ? (
<div className="mt-8 mb-12 space-x-3 text-dark"> <div className="mt-8 mb-12 space-x-3 text-dark">
<Ico icon={Icon.Emergency} /> <Ico icon="emergency" />
<Ico icon={Icon.Emergency} /> <Ico icon="emergency" />
<Ico icon={Icon.Emergency} /> <Ico icon="emergency" />
</div> </div>
) : ( ) : (
<div className="font-headers">{title}</div> <div className="font-headers">{title}</div>

View File

@ -0,0 +1,21 @@
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
/*
*
* COMPONENT
*/
interface MarkdownProps {
className?: string;
text: string;
}
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
export const Markdown = ({ className, text }: MarkdownProps): JSX.Element => (
<div
className={className}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(text)) }}
/>
);

View File

@ -1,6 +1,7 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { MouseEventHandler, useCallback } from "react"; import { MouseEventHandler, useCallback } from "react";
import { Ico, Icon } from "components/Ico"; import { MaterialSymbol } from "material-symbols";
import { Ico } from "components/Ico";
import { ToolTip } from "components/ToolTip"; import { ToolTip } from "components/ToolTip";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { isDefinedAndNotEmpty } from "helpers/asserts"; import { isDefinedAndNotEmpty } from "helpers/asserts";
@ -15,7 +16,7 @@ import { DownPressable } from "components/Containers/DownPressable";
interface Props { interface Props {
url: string; url: string;
icon?: Icon; icon?: MaterialSymbol;
title: string | null | undefined; title: string | null | undefined;
subtitle?: string | null | undefined; subtitle?: string | null | undefined;
border?: boolean; border?: boolean;

View File

@ -1,4 +1,5 @@
import { Ico, Icon } from "components/Ico"; import { MaterialSymbol } from "material-symbols";
import { Ico } from "components/Ico";
import { isDefinedAndNotEmpty } from "helpers/asserts"; import { isDefinedAndNotEmpty } from "helpers/asserts";
/* /*
@ -7,7 +8,7 @@ import { isDefinedAndNotEmpty } from "helpers/asserts";
*/ */
interface Props { interface Props {
icon?: Icon; icon?: MaterialSymbol;
title: string | null | undefined; title: string | null | undefined;
description?: string | null | undefined; description?: string | null | undefined;
} }

View File

@ -1,5 +1,4 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Icon } from "components/Ico";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
@ -32,7 +31,7 @@ export const ReturnButton = ({ href, title, displayOnlyOn, className }: Props):
(!is3ColumnsLayout && displayOnlyOn === "1ColumnLayout") || (!is3ColumnsLayout && displayOnlyOn === "1ColumnLayout") ||
isUndefined(displayOnlyOn)) && ( isUndefined(displayOnlyOn)) && (
<div className={className}> <div className={className}>
<Button href={href} text={`${langui.return_to} ${title}`} icon={Icon.NavigateBefore} /> <Button href={href} text={`${langui.return_to} ${title}`} icon="navigate_before" />
</div> </div>
)} )}
</> </>

View File

@ -2,7 +2,6 @@ import { HorizontalLine } from "components/HorizontalLine";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { NavOption } from "components/PanelComponents/NavOption"; import { NavOption } from "components/PanelComponents/NavOption";
import { ToolTip } from "components/ToolTip"; import { ToolTip } from "components/ToolTip";
import { Icon } from "components/Ico";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { isDefinedAndNotEmpty } from "helpers/asserts"; import { isDefinedAndNotEmpty } from "helpers/asserts";
import { Link } from "components/Inputs/Link"; import { Link } from "components/Inputs/Link";
@ -22,6 +21,7 @@ export const MainPanel = (): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
const [isMainPanelReduced, setMainPanelReduced] = useAtomPair(atoms.layout.mainPanelReduced); const [isMainPanelReduced, setMainPanelReduced] = useAtomPair(atoms.layout.mainPanelReduced);
const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened); const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened);
const setSearchOpened = useAtomSetter(atoms.layout.searchOpened);
return ( return (
<div <div
@ -46,7 +46,7 @@ export const MainPanel = (): JSX.Element => {
setMainPanelReduced((current) => !current); setMainPanelReduced((current) => !current);
}} }}
className="z-50 bg-light !px-2" className="z-50 bg-light !px-2"
icon={isMainPanelReduced ? Icon.ChevronRight : Icon.ChevronLeft} icon={isMainPanelReduced ? "chevron_right" : "chevron_left"}
/> />
</div> </div>
)} )}
@ -73,15 +73,24 @@ export const MainPanel = (): JSX.Element => {
)}> )}>
<ToolTip <ToolTip
content={<h3 className="text-2xl">{langui.open_settings}</h3>} content={<h3 className="text-2xl">{langui.open_settings}</h3>}
placement="right" placement={isMainPanelReduced ? "right" : "top"}>
className="text-left"
disabled={!isMainPanelReduced}>
<Button <Button
onClick={() => { onClick={() => {
setSettingsOpened(true); setSettingsOpened(true);
sendAnalytics("Settings", "Open settings"); sendAnalytics("Settings", "Open settings");
}} }}
icon={Icon.Settings} icon="discover_tune"
/>
</ToolTip>
<ToolTip
content={<h3 className="text-2xl">{langui.open_search}</h3>}
placement={isMainPanelReduced ? "right" : "top"}>
<Button
onClick={() => {
setSearchOpened(true);
sendAnalytics("Search", "Open search");
}}
icon="search"
/> />
</ToolTip> </ToolTip>
</div> </div>
@ -92,7 +101,7 @@ export const MainPanel = (): JSX.Element => {
<NavOption <NavOption
url="/library" url="/library"
icon={Icon.LibraryBooks} icon="auto_stories"
title={langui.library} title={langui.library}
subtitle={langui.library_short_description} subtitle={langui.library_short_description}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
@ -100,7 +109,7 @@ export const MainPanel = (): JSX.Element => {
<NavOption <NavOption
url="/contents" url="/contents"
icon={Icon.Workspaces} icon="workspaces"
title={langui.contents} title={langui.contents}
subtitle={langui.contents_short_description} subtitle={langui.contents_short_description}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
@ -108,7 +117,7 @@ export const MainPanel = (): JSX.Element => {
<NavOption <NavOption
url="/wiki" url="/wiki"
icon={Icon.TravelExplore} icon="travel_explore"
title={langui.wiki} title={langui.wiki}
subtitle={langui.wiki_short_description} subtitle={langui.wiki_short_description}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
@ -116,7 +125,7 @@ export const MainPanel = (): JSX.Element => {
<NavOption <NavOption
url="/chronicles" url="/chronicles"
icon={Icon.WatchLater} icon="schedule"
title={langui.chronicles} title={langui.chronicles}
subtitle={langui.chronicles_short_description} subtitle={langui.chronicles_short_description}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
@ -126,7 +135,7 @@ export const MainPanel = (): JSX.Element => {
<NavOption <NavOption
url="/news" url="/news"
icon={Icon.Feed} icon="newspaper"
title={langui.news} title={langui.news}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
/> />
@ -134,7 +143,7 @@ export const MainPanel = (): JSX.Element => {
{/* {/*
<NavOption <NavOption
url="/merch" url="/merch"
icon={Icon.Store} icon="store"
title={langui.merch} title={langui.merch}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
/> />
@ -142,21 +151,21 @@ export const MainPanel = (): JSX.Element => {
<NavOption <NavOption
url="https://gallery.accords-library.com/posts/" url="https://gallery.accords-library.com/posts/"
icon={Icon.Collections} icon="perm_media"
title={langui.gallery} title={langui.gallery}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
/> />
<NavOption <NavOption
url="/archives" url="/archives"
icon={Icon.Inventory2} icon="save"
title={langui.archives} title={langui.archives}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
/> />
<NavOption <NavOption
url="/about-us" url="/about-us"
icon={Icon.Info} icon="info"
title={langui.about_us} title={langui.about_us}
reduced={isMainPanelReduced && is3ColumnsLayout} reduced={isMainPanelReduced && is3ColumnsLayout}
/> />

View File

@ -0,0 +1,449 @@
import { useEffect, useState } from "react";
import { MaterialSymbol } from "material-symbols";
import { Popup } from "components/Containers/Popup";
import { sendAnalytics } from "helpers/analytics";
import { atoms } from "contexts/atoms";
import { useAtomPair, useAtomGetter } from "helpers/atoms";
import { TextInput } from "components/Inputs/TextInput";
import { containsHighlight, CustomSearchResponse, meiliSearch } from "helpers/search";
import { PreviewCard, TranslatedPreviewCard } from "components/PreviewCard";
import { filterDefined, filterHasAttributes, isDefined } from "helpers/asserts";
import {
MeiliContent,
MeiliIndices,
MeiliLibraryItem,
MeiliPost,
MeiliVideo,
MeiliWikiPage,
} from "shared/meilisearch-graphql-typings/meiliTypes";
import { getVideoThumbnailURL } from "helpers/videos";
import { UpPressable } from "components/Containers/UpPressable";
import { prettyItemSubType, prettySlug } from "helpers/formatters";
import { Ico } from "components/Ico";
/*
*
* CONSTANTS
*/
const SEARCH_LIMIT = 8;
/*
*
* COMPONENT
*/
export const SearchPopup = (): JSX.Element => {
const [isSearchOpened, setSearchOpened] = useAtomPair(atoms.layout.searchOpened);
const [query, setQuery] = useState("");
const langui = useAtomGetter(atoms.localData.langui);
const [libraryItems, setLibraryItems] = useState<CustomSearchResponse<MeiliLibraryItem>>();
const [contents, setContents] = useState<CustomSearchResponse<MeiliContent>>();
const [videos, setVideos] = useState<CustomSearchResponse<MeiliVideo>>();
const [posts, setPosts] = useState<CustomSearchResponse<MeiliPost>>();
const [wikiPages, setWikiPages] = useState<CustomSearchResponse<MeiliWikiPage>>();
useEffect(() => {
const fetchLibraryItems = async () => {
const searchResult = await meiliSearch(MeiliIndices.LIBRARY_ITEM, query, {
limit: SEARCH_LIMIT,
attributesToRetrieve: [
"title",
"subtitle",
"descriptions",
"id",
"slug",
"thumbnail",
"release_date",
"price",
"categories",
"metadata",
],
attributesToHighlight: ["title", "subtitle", "descriptions"],
attributesToCrop: ["descriptions"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("descriptions"))) {
item._formatted.descriptions = filterDefined(item._formatted.descriptions).filter(
(description) => containsHighlight(JSON.stringify(description))
);
}
return item;
});
setLibraryItems(searchResult);
};
const fetchContents = async () => {
const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, {
limit: SEARCH_LIMIT,
attributesToRetrieve: ["translations", "id", "slug", "categories", "type", "thumbnail"],
attributesToHighlight: ["translations"],
attributesToCrop: ["translations.displayable_description"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("translations"))) {
item._formatted.translations = filterDefined(item._formatted.translations).filter(
(translation) => containsHighlight(JSON.stringify(translation))
);
}
return item;
});
setContents(searchResult);
};
const fetchVideos = async () => {
const searchResult = await meiliSearch(MeiliIndices.VIDEOS, query, {
limit: SEARCH_LIMIT,
attributesToRetrieve: [
"title",
"channel",
"uid",
"published_date",
"views",
"duration",
"description",
],
attributesToHighlight: ["title", "channel", "description"],
attributesToCrop: ["description"],
});
setVideos(searchResult);
};
const fetchPosts = async () => {
const searchResult = await meiliSearch(MeiliIndices.POST, query, {
limit: SEARCH_LIMIT,
attributesToRetrieve: ["translations", "thumbnail", "slug", "date", "categories"],
attributesToHighlight: ["translations.title", "translations.excerpt", "translations.body"],
attributesToCrop: ["translations.body"],
filter: ["hidden = false"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("translations"))) {
item._formatted.translations = filterDefined(item._formatted.translations).filter(
(translation) => JSON.stringify(translation).includes("</mark>")
);
}
return item;
});
setPosts(searchResult);
};
const fetchWikiPages = async () => {
const searchResult = await meiliSearch(MeiliIndices.WIKI_PAGE, query, {
limit: SEARCH_LIMIT,
attributesToHighlight: [
"translations.title",
"translations.aliases",
"translations.summary",
"translations.displayable_description",
],
attributesToCrop: ["translations.displayable_description"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (
Object.keys(item._matchesPosition).filter((match) => match.startsWith("translations"))
.length > 0
) {
item._formatted.translations = filterDefined(item._formatted.translations).filter(
(translation) => JSON.stringify(translation).includes("</mark>")
);
}
return item;
});
setWikiPages(searchResult);
};
if (query === "") {
setWikiPages(undefined);
setLibraryItems(undefined);
setContents(undefined);
setVideos(undefined);
setPosts(undefined);
} else {
fetchWikiPages();
fetchLibraryItems();
fetchContents();
fetchVideos();
fetchPosts();
}
}, [query]);
return (
<Popup
isVisible={isSearchOpened}
onCloseRequest={() => {
setSearchOpened(false);
sendAnalytics("Search", "Close search");
}}
fillViewport>
<h2 className="inline-flex place-items-center gap-2 text-2xl">
<Ico icon="search" isFilled />
{langui.search}
</h2>
<TextInput onChange={setQuery} value={query} placeholder={langui.search_title} />
<div className="flex w-full flex-wrap gap-12 gap-x-16">
{isDefined(libraryItems) && (
<SearchResultSection
title={langui.library}
icon="auto_stories"
href={`/library?page=1&query=${query}\
&sort=0&primary=true&secondary=true&subitems=true&status=all`}
totalHits={libraryItems.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{libraryItems.hits.map((item) => (
<TranslatedPreviewCard
key={item.id}
className="w-56"
href={`/library/${item.slug}`}
translations={filterHasAttributes(item._formatted.descriptions, [
"language.data.attributes.code",
] as const).map((translation) => ({
language: translation.language.data.attributes.code,
title: item.title,
subtitle: item.subtitle,
description: containsHighlight(translation.description)
? translation.description
: undefined,
}))}
fallback={{ title: item._formatted.title, subtitle: item._formatted.subtitle }}
thumbnail={item.thumbnail?.data?.attributes}
thumbnailAspectRatio="21/29.7"
thumbnailRounded={false}
keepInfoVisible
topChips={
item.metadata && item.metadata.length > 0 && item.metadata[0]
? [prettyItemSubType(item.metadata[0])]
: []
}
bottomChips={item.categories?.data.map(
(category) => category.attributes?.short ?? ""
)}
metadata={{
releaseDate: item.release_date,
price: item.price,
position: "Bottom",
}}
/>
))}
</div>
</SearchResultSection>
)}
{isDefined(contents) && (
<SearchResultSection
title={langui.contents}
icon="workspaces"
href={`/contents/all?page=1&query=${query}&sort=0`}
totalHits={contents.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{contents.hits.map((item) => (
<TranslatedPreviewCard
key={item.id}
className="w-56"
href={`/contents/${item.slug}`}
translations={filterHasAttributes(item._formatted.translations, [
"language.data.attributes.code",
] as const).map(({ displayable_description, language, ...otherAttributes }) => ({
...otherAttributes,
description: containsHighlight(displayable_description)
? displayable_description
: undefined,
language: language.data.attributes.code,
}))}
fallback={{ title: prettySlug(item.slug) }}
thumbnail={item.thumbnail?.data?.attributes}
thumbnailAspectRatio="3/2"
thumbnailForceAspectRatio
topChips={
item.type?.data?.attributes
? [
item.type.data.attributes.titles?.[0]
? item.type.data.attributes.titles[0]?.title
: prettySlug(item.type.data.attributes.slug),
]
: undefined
}
bottomChips={item.categories?.data.map(
(category) => category.attributes?.short ?? ""
)}
keepInfoVisible
/>
))}
</div>
</SearchResultSection>
)}
{isDefined(wikiPages) && (
<SearchResultSection
title={langui.wiki}
icon="travel_explore"
href={`/wiki?page=1&query=${query}`}
totalHits={wikiPages.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{wikiPages.hits.map((item) => (
<TranslatedPreviewCard
key={item.id}
className="w-56"
href={`/wiki/${item.slug}`}
translations={filterHasAttributes(item._formatted.translations, [
"language.data.attributes.code",
] as const).map(
({
aliases,
summary,
displayable_description,
language,
...otherAttributes
}) => ({
...otherAttributes,
subtitle:
aliases && aliases.length > 0
? aliases.map((alias) => alias?.alias).join("・")
: undefined,
description: containsHighlight(displayable_description)
? displayable_description
: summary,
language: language.data.attributes.code,
})
)}
fallback={{ title: prettySlug(item.slug) }}
thumbnail={item.thumbnail?.data?.attributes}
thumbnailAspectRatio={"4/3"}
thumbnailRounded
thumbnailForceAspectRatio
keepInfoVisible
topChips={filterHasAttributes(item.tags?.data, ["attributes"] as const).map(
(tag) => tag.attributes.titles?.[0]?.title ?? prettySlug(tag.attributes.slug)
)}
bottomChips={filterHasAttributes(item.categories?.data, [
"attributes",
] as const).map((category) => category.attributes.short)}
/>
))}
</div>
</SearchResultSection>
)}
{isDefined(posts) && (
<SearchResultSection
title={langui.news}
icon="newspaper"
href={`/news?page=1&query=${query}`}
totalHits={posts.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{posts.hits.map((item) => (
<TranslatedPreviewCard
className="w-56"
key={item.id}
href={`/news/${item.slug}`}
translations={filterHasAttributes(item._formatted.translations, [
"language.data.attributes.code",
] as const).map(({ excerpt, body, language, ...otherAttributes }) => ({
...otherAttributes,
description: containsHighlight(excerpt)
? excerpt
: containsHighlight(body)
? body
: excerpt,
language: language.data.attributes.code,
}))}
fallback={{ title: prettySlug(item.slug) }}
thumbnail={item.thumbnail?.data?.attributes}
thumbnailAspectRatio="3/2"
thumbnailForceAspectRatio
keepInfoVisible
bottomChips={item.categories?.data.map(
(category) => category.attributes?.short ?? ""
)}
metadata={{
releaseDate: item.date,
releaseDateFormat: "long",
position: "Top",
}}
/>
))}
</div>
</SearchResultSection>
)}
{isDefined(videos) && (
<SearchResultSection
title={langui.videos}
icon="movie"
href={`/archives/videos?page=1&query=${query}&sort=1&gone=`}
totalHits={videos.estimatedTotalHits}>
<div className="flex flex-wrap items-start gap-x-6 gap-y-8">
{videos.hits.map((item) => (
<PreviewCard
className="w-56"
key={item.uid}
href={`/archives/videos/v/${item.uid}`}
title={item._formatted.title}
thumbnail={getVideoThumbnailURL(item.uid)}
thumbnailAspectRatio="16/9"
thumbnailForceAspectRatio
keepInfoVisible
metadata={{
releaseDate: item.published_date,
views: item.views,
author: item._formatted.channel?.data?.attributes?.title,
position: "Top",
}}
description={
item._matchesPosition.description &&
item._matchesPosition.description.length > 0
? item._formatted.description
: undefined
}
hoverlay={{
__typename: "Video",
duration: item.duration,
}}
/>
))}
</div>
</SearchResultSection>
)}
</div>
</Popup>
);
};
interface SearchResultSectionProps {
title?: string | null;
icon: MaterialSymbol;
href: string;
totalHits?: number;
children: React.ReactNode;
}
const SearchResultSection = ({
title,
icon,
href,
totalHits,
children,
}: SearchResultSectionProps) => (
<>
{isDefined(totalHits) && totalHits > 0 && (
<div>
<div className="mb-6 grid place-content-start">
<UpPressable
className="grid grid-cols-[auto_1fr] place-items-center gap-6 px-6 py-4"
href={href}>
<Ico icon={icon} className="!text-3xl" isFilled />
<div>
<p className="font-headers text-lg">{title}</p>
{isDefined(totalHits) && totalHits > SEARCH_LIMIT && (
/* TODO: Langui */
<p className="text-sm">{`(showing ${SEARCH_LIMIT} out of ${totalHits} results)`}</p>
)}
</div>
</UpPressable>
</div>
{children}
</div>
)}
</>
);

View File

@ -1,6 +1,5 @@
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Icon } from "components/Ico";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { ButtonGroup } from "components/Inputs/ButtonGroup"; import { ButtonGroup } from "components/Inputs/ButtonGroup";
import { OrderableList } from "components/Inputs/OrderableList"; import { OrderableList } from "components/Inputs/OrderableList";
@ -14,6 +13,7 @@ import { filterHasAttributes, isDefined } from "helpers/asserts";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter, useAtomPair } from "helpers/atoms"; import { useAtomGetter, useAtomPair } from "helpers/atoms";
import { ThemeMode } from "contexts/settings"; import { ThemeMode } from "contexts/settings";
import { Ico } from "components/Ico";
export const SettingsPopup = (): JSX.Element => { export const SettingsPopup = (): JSX.Element => {
const [preferredLanguages, setPreferredLanguages] = useAtomPair( const [preferredLanguages, setPreferredLanguages] = useAtomPair(
@ -50,7 +50,10 @@ export const SettingsPopup = (): JSX.Element => {
setSettingsOpened(false); setSettingsOpened(false);
sendAnalytics("Settings", "Close settings"); sendAnalytics("Settings", "Close settings");
}}> }}>
<h2 className="text-2xl">{langui.settings}</h2> <h2 className="inline-flex place-items-center gap-2 text-2xl">
<Ico icon="discover_tune" isFilled />
{langui.settings}
</h2>
<div <div
className={cJoin( className={cJoin(
@ -154,7 +157,7 @@ export const SettingsPopup = (): JSX.Element => {
})}%)` })}%)`
); );
}, },
icon: Icon.TextDecrease, icon: "text_decrease",
}, },
{ {
onClick: () => { onClick: () => {
@ -175,7 +178,7 @@ export const SettingsPopup = (): JSX.Element => {
})}%)` })}%)`
); );
}, },
icon: Icon.TextIncrease, icon: "text_increase",
}, },
]} ]}
/> />

View File

@ -1,9 +1,10 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Chip } from "./Chip"; import { Markdown } from "./Markdown/Markdown";
import { Ico, Icon } from "./Ico"; import { Chip } from "components/Chip";
import { Img } from "./Img"; import { Ico } from "components/Ico";
import { UpPressable } from "./Containers/UpPressable"; import { Img } from "components/Img";
import { UpPressable } from "components/Containers/UpPressable";
import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated"; import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated";
import { cIf, cJoin } from "helpers/className"; import { cIf, cJoin } from "helpers/className";
import { prettyDate, prettyDuration, prettyPrice, prettyShortenNumber } from "helpers/formatters"; import { prettyDate, prettyDuration, prettyPrice, prettyShortenNumber } from "helpers/formatters";
@ -48,6 +49,7 @@ interface Props {
} }
| { __typename: "anotherHoverlayName" }; | { __typename: "anotherHoverlayName" };
disabled?: boolean; disabled?: boolean;
className?: string;
} }
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
@ -68,6 +70,7 @@ export const PreviewCard = ({
metadata, metadata,
hoverlay, hoverlay,
infoAppend, infoAppend,
className,
disabled = false, disabled = false,
}: Props): JSX.Element => { }: Props): JSX.Element => {
const currency = useAtomGetter(atoms.settings.currency); const currency = useAtomGetter(atoms.settings.currency);
@ -81,26 +84,26 @@ export const PreviewCard = ({
<div className="flex w-full flex-row flex-wrap gap-x-3"> <div className="flex w-full flex-row flex-wrap gap-x-3">
{metadata.releaseDate && ( {metadata.releaseDate && (
<p className="text-sm"> <p className="text-sm">
<Ico icon={Icon.Event} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="event" className="mr-1 translate-y-[.15em] !text-base" />
{prettyDate(metadata.releaseDate, router.locale)} {prettyDate(metadata.releaseDate, router.locale)}
</p> </p>
)} )}
{metadata.price && ( {metadata.price && (
<p className="justify-self-end text-sm"> <p className="justify-self-end text-sm">
<Ico icon={Icon.ShoppingCart} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="shopping_cart" className="mr-1 translate-y-[.15em] !text-base" />
{prettyPrice(metadata.price, currencies, currency)} {prettyPrice(metadata.price, currencies, currency)}
</p> </p>
)} )}
{metadata.views && ( {metadata.views && (
<p className="text-sm"> <p className="text-sm">
<Ico icon={Icon.Visibility} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="visibility" className="mr-1 translate-y-[.15em] !text-base" />
{prettyShortenNumber(metadata.views)} {prettyShortenNumber(metadata.views)}
</p> </p>
)} )}
{metadata.author && ( {metadata.author && (
<p className="text-sm"> <p className="text-sm">
<Ico icon={Icon.Person} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="person" className="mr-1 translate-y-[.15em] !text-base" />
{metadata.author} <Markdown text={metadata.author} className="inline-block" />
</p> </p>
)} )}
</div> </div>
@ -109,7 +112,11 @@ export const PreviewCard = ({
); );
return ( return (
<UpPressable className="grid items-end text-left" href={href} noBackground disabled={disabled}> <UpPressable
className={cJoin("grid items-end text-left", className)}
href={href}
noBackground
disabled={disabled}>
<div className={cJoin("group", cIf(disabled, "pointer-events-none touch-none select-none"))}> <div className={cJoin("group", cIf(disabled, "pointer-events-none touch-none select-none"))}>
{thumbnail ? ( {thumbnail ? (
<div <div
@ -132,17 +139,16 @@ export const PreviewCard = ({
{hoverlay && hoverlay.__typename === "Video" && ( {hoverlay && hoverlay.__typename === "Video" && (
<> <>
<div <div
className="group absolute inset-0 grid place-content-center bg-shade bg-opacity-0 className="absolute inset-0 grid place-content-center bg-shade bg-opacity-0
text-light transition-colors text-light transition-colors group-hover:bg-opacity-50">
hover:bg-opacity-50">
<Ico <Ico
icon={Icon.PlayCircleOutline} icon="play_circle"
className="!text-6xl text-black opacity-0 drop-shadow-lg transition-opacity className="!text-6xl text-light opacity-0 drop-shadow-lg transition-opacity
shadow-shade group-hover:opacity-100" shadow-shade group-hover:opacity-100 dark:text-black"
/> />
</div> </div>
<div <div
className="absolute right-2 bottom-2 rounded-full bg-black bg-opacity-60 px-2 className="absolute right-2 bottom-2 rounded-full bg-black/60 px-2
text-light"> text-light">
{prettyDuration(hoverlay.duration)} {prettyDuration(hoverlay.duration)}
</div> </div>
@ -174,24 +180,27 @@ export const PreviewCard = ({
{topChips && topChips.length > 0 && ( {topChips && topChips.length > 0 && (
<div <div
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
scrollbar-none"> scrollbar-none">
{topChips.map((text, index) => ( {topChips.map((text, index) => (
<Chip key={index} text={text} /> <Chip key={index} text={text} />
))} ))}
</div> </div>
)} )}
<div className="my-1"> <div className="my-1">
{pre_title && <p className="mb-1 leading-none break-words">{pre_title}</p>} {pre_title && <Markdown text={pre_title} className="mb-1 leading-none break-words" />}
{title && ( {title && (
<p className="font-headers text-lg font-bold leading-none break-words">{title}</p> <Markdown
text={title}
className="font-headers text-lg font-bold leading-none break-words"
/>
)} )}
{subtitle && <p className="leading-none break-words">{subtitle}</p>} {subtitle && <Markdown text={subtitle} className="leading-none break-words" />}
</div> </div>
{description && <p>{description}</p>} {description && <Markdown text={description} className="break-words" />}
{bottomChips && bottomChips.length > 0 && ( {bottomChips && bottomChips.length > 0 && (
<div <div
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
scrollbar-none"> scrollbar-none">
{bottomChips.map((text, index) => ( {bottomChips.map((text, index) => (
<Chip key={index} className="text-sm" text={text} /> <Chip key={index} className="text-sm" text={text} />
))} ))}

View File

@ -3,7 +3,7 @@ import { useHotkeys } from "react-hotkeys-hook";
import naturalCompare from "string-natural-compare"; import naturalCompare from "string-natural-compare";
import { Chip } from "./Chip"; import { Chip } from "./Chip";
import { PageSelector } from "./Inputs/PageSelector"; import { PageSelector } from "./Inputs/PageSelector";
import { Ico, Icon } from "./Ico"; import { Ico } from "./Ico";
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts"; import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange"; import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
@ -70,10 +70,10 @@ export const SmartList = <T,>({
sortingFunction = defaultSortingFunction, sortingFunction = defaultSortingFunction,
className, className,
}: Props<T>): JSX.Element => { }: Props<T>): JSX.Element => {
const [page, setPage] = useState(0); const [page, setPage] = useState(1);
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
useScrollTopOnChange(Ids.ContentPanel, [page], paginationScroolTop); useScrollTopOnChange(Ids.ContentPanel, [page], paginationScroolTop);
useEffect(() => setPage(0), [searchingTerm, groupingFunction, groupSortingFunction]); useEffect(() => setPage(1), [searchingTerm, groupingFunction, groupSortingFunction]);
const searchFilter = useCallback(() => { const searchFilter = useCallback(() => {
if (isDefinedAndNotEmpty(searchingTerm) && isDefined(searchingBy)) { if (isDefinedAndNotEmpty(searchingTerm) && isDefined(searchingBy)) {
@ -158,9 +158,9 @@ export const SmartList = <T,>({
return memo; return memo;
})(); })();
useHotkeys("left", () => setPage((current) => current - 1), { enabled: page > 0 }); useHotkeys("left", () => setPage((current) => current - 1), { enabled: page > 1 });
useHotkeys("right", () => setPage((current) => current + 1), { useHotkeys("right", () => setPage((current) => current + 1), {
enabled: page < pages.length - 1, enabled: page < pages.length,
}); });
return ( return (
@ -170,8 +170,8 @@ export const SmartList = <T,>({
)} )}
<div className="mb-8"> <div className="mb-8">
{(pages[page]?.length ?? 0) > 0 ? ( {(pages[page - 1]?.length ?? 0) > 0 ? (
pages[page]?.map( pages[page - 1]?.map(
(group) => (group) =>
group.items.length > 0 && ( group.items.length > 0 && (
<Fragment key={group.name}> <Fragment key={group.name}>
@ -229,9 +229,9 @@ const DefaultRenderWhenEmpty = () => {
<div <div
className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted className="grid grid-flow-col place-items-center gap-9 rounded-2xl border-2 border-dotted
border-dark p-8 text-dark opacity-40"> border-dark p-8 text-dark opacity-40">
{is3ColumnsLayout && <Ico icon={Icon.ChevronLeft} className="!text-[300%]" />} {is3ColumnsLayout && <Ico icon="chevron_left" className="!text-[300%]" />}
<p className="max-w-xs text-2xl">{langui.no_results_message}</p> <p className="max-w-xs text-2xl">{langui.no_results_message}</p>
{!is3ColumnsLayout && <Ico icon={Icon.ChevronRight} className="!text-[300%]" />} {!is3ColumnsLayout && <Ico icon="chevron_right" className="!text-[300%]" />}
</div> </div>
</div> </div>
); );

View File

@ -7,6 +7,7 @@ import { atoms } from "contexts/atoms";
export const useAppLayout = (): void => { export const useAppLayout = (): void => {
const router = useRouter(); const router = useRouter();
const setSearchOpened = useAtomSetter(atoms.layout.searchOpened);
const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened); const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened);
const setMainPanelOpened = useAtomSetter(atoms.layout.mainPanelOpened); const setMainPanelOpened = useAtomSetter(atoms.layout.mainPanelOpened);
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened); const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
@ -14,6 +15,7 @@ export const useAppLayout = (): void => {
useEffect(() => { useEffect(() => {
router.events.on("routeChangeStart", () => { router.events.on("routeChangeStart", () => {
console.log("[Router Events] on routeChangeStart"); console.log("[Router Events] on routeChangeStart");
setSearchOpened(false);
setSettingsOpened(false); setSettingsOpened(false);
setMainPanelOpened(false); setMainPanelOpened(false);
setSubPanelOpened(false); setSubPanelOpened(false);
@ -23,7 +25,7 @@ export const useAppLayout = (): void => {
console.log("[Router Events] on hashChangeStart"); console.log("[Router Events] on hashChangeStart");
setSubPanelOpened(false); setSubPanelOpened(false);
}); });
}, [router, setSettingsOpened, setMainPanelOpened, setSubPanelOpened]); }, [router, setSettingsOpened, setMainPanelOpened, setSubPanelOpened, setSearchOpened]);
useScrollIntoView(); useScrollIntoView();
}; };

View File

@ -14,13 +14,15 @@ import { lightBox } from "contexts/LightBoxProvider";
/* [ APPLAYOUT ATOMS ] */ /* [ APPLAYOUT ATOMS ] */
const mainPanelReduced = atomPairing(atomWithStorage("isMainPanelReduced", false)); const mainPanelReduced = atomPairing(atomWithStorage("isMainPanelReduced", false));
const settingsOpened = atomPairing(atomWithStorage("isSettingsOpened", false)); const searchOpened = atomPairing(atom(false));
const subPanelOpened = atomPairing(atomWithStorage("isSubPanelOpened", false)); const settingsOpened = atomPairing(atom(false));
const mainPanelOpened = atomPairing(atomWithStorage("isMainPanelOpened", false)); const subPanelOpened = atomPairing(atom(false));
const mainPanelOpened = atomPairing(atom(false));
const menuGesturesEnabled = atomPairing(atomWithStorage("isMenuGesturesEnabled", false)); const menuGesturesEnabled = atomPairing(atomWithStorage("isMenuGesturesEnabled", false));
const terminalMode = atom((get) => get(settings.playerName[0]) === "root"); const terminalMode = atom((get) => get(settings.playerName[0]) === "root");
const layout = { const layout = {
searchOpened,
mainPanelReduced, mainPanelReduced,
settingsOpened, settingsOpened,
subPanelOpened, subPanelOpened,

View File

@ -1,75 +0,0 @@
query getContents($language_code: String) {
contents(pagination: { limit: -1 }) {
data {
id
attributes {
slug
translations(pagination: { limit: -1 }) {
pre_title
title
subtitle
language {
data {
attributes {
code
}
}
}
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
type {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
ranged_contents(pagination: { limit: -1 }) {
data {
id
attributes {
slug
scan_set {
id
}
library_item {
data {
attributes {
slug
title
subtitle
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}
}
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}
}
}

View File

@ -1,107 +0,0 @@
query getLibraryItemsPreview($language_code: String) {
libraryItems(pagination: { limit: -1 }) {
data {
id
attributes {
title
subtitle
slug
root_item
primary
thumbnail {
data {
attributes {
...uploadImage
}
}
}
release_date {
...datePicker
}
price {
...pricePicker
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
metadata {
__typename
... on ComponentMetadataBooks {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataGame {
platforms(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
}
... on ComponentMetadataVideo {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataAudio {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
... on ComponentMetadataGroup {
subtype {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
subitems_type {
data {
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
title
}
}
}
}
}
}
}
}
}
}

View File

@ -1,46 +0,0 @@
query getPostsPreview {
posts(filters: { hidden: { eq: false } }) {
data {
id
attributes {
slug
date {
...datePicker
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
thumbnail {
data {
attributes {
...uploadImage
}
}
}
translations(pagination: { limit: -1 }) {
language {
data {
attributes {
code
}
}
}
title
excerpt
thumbnail {
data {
attributes {
...uploadImage
}
}
}
}
}
}
}
}

View File

@ -5,29 +5,6 @@ query getVideoChannel($channel: String) {
uid uid
title title
subscribers subscribers
videos(pagination: { limit: -1 }) {
data {
id
attributes {
uid
title
views
duration
gone
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
published_date {
...datePicker
}
}
}
}
} }
} }
} }

View File

@ -1,33 +0,0 @@
query getVideosPreview {
videos(pagination: { limit: -1 }) {
data {
id
attributes {
uid
title
views
duration
gone
categories(pagination: { limit: -1 }) {
data {
id
attributes {
short
}
}
}
published_date {
...datePicker
}
channel {
data {
attributes {
uid
title
}
}
}
}
}
}
}

View File

@ -1,58 +0,0 @@
query getWikiPagesPreviews($language_code: String) {
wikiPages(pagination: { limit: -1 }) {
data {
id
attributes {
slug
thumbnail {
data {
attributes {
...uploadImage
}
}
}
categories(pagination: { limit: -1 }) {
data {
id
attributes {
name
short
}
}
}
tags(pagination: { limit: -1 }) {
data {
id
attributes {
slug
titles(filters: { language: { code: { eq: $language_code } } }) {
language {
data {
attributes {
code
}
}
}
title
}
}
}
}
translations(pagination: { limit: -1 }) {
title
aliases(pagination: { limit: -1 }) {
alias
}
summary
language {
data {
attributes {
code
}
}
}
}
}
}
}
}

View File

@ -191,6 +191,14 @@ query localDataGetWebsiteInterfaces {
reset_all_options reset_all_options
reading_layout reading_layout
quality quality
only_unavailable_videos
oldest
newest
least_popular
most_popular
shortest
longest
search
} }
} }
} }

View File

@ -1,6 +1,8 @@
export const sendAnalytics = (category: string, event: string): void => { export const sendAnalytics = (category: string, event: string): void => {
const eventName = `[${category}] ${event}`;
console.log(`Event: ${eventName}`);
try { try {
umami(`[${category}] ${event}`); umami(eventName);
} catch (error) { } catch (error) {
if (error instanceof ReferenceError) return; if (error instanceof ReferenceError) return;
console.log(error); console.log(error);

View File

@ -36,7 +36,7 @@ type Split<Str, Acc extends string[] = []> = Str extends `${infer Head}.${infer
? [...Acc, Last] ? [...Acc, Last]
: never; : never;
export type SelectiveNonNullable<T, P extends PathDot<T>> = Recursive<NonNullable<T>, Split<P>>; type SelectiveNonNullable<T, P extends PathDot<T>> = Recursive<NonNullable<T>, Split<P>>;
export const isDefined = <T>(t: T): t is NonNullable<T> => t !== null && t !== undefined; export const isDefined = <T>(t: T): t is NonNullable<T> => t !== null && t !== undefined;

View File

@ -1,5 +1,10 @@
import { isUndefined } from "./asserts"; import { isDefined, isUndefined } from "./asserts";
import { DatePickerFragment } from "graphql/generated";
type DatePickerFragment = {
year?: number | null;
month?: number | null;
day?: number | null;
};
export const compareDate = ( export const compareDate = (
a: DatePickerFragment | null | undefined, a: DatePickerFragment | null | undefined,
@ -8,10 +13,11 @@ export const compareDate = (
if (isUndefined(a) || isUndefined(b)) { if (isUndefined(a) || isUndefined(b)) {
return 0; return 0;
} }
const dateA = (a.year ?? Infinity) * 365 + (a.month ?? 12) * 31 + (a.day ?? 31); return dateInDays(a) - dateInDays(b);
const dateB = (b.year ?? Infinity) * 365 + (b.month ?? 12) * 31 + (b.day ?? 31);
return dateA - dateB;
}; };
const dateInDays = (date: DatePickerFragment | null | undefined): number =>
isDefined(date) ? (date.year ?? Infinity) * 365 + (date.month ?? 12) * 31 + (date.day ?? 31) : 0;
export const datePickerToDate = (date: DatePickerFragment): Date => export const datePickerToDate = (date: DatePickerFragment): Date =>
new Date(date.year ?? 0, date.month ? date.month - 1 : 0, date.day ?? 1); new Date(date.year ?? 0, date.month ? date.month - 1 : 0, date.day ?? 1);

View File

@ -208,12 +208,12 @@ export const prettyItemSubType = (
/* eslint-enable id-denylist */ /* eslint-enable id-denylist */
export const prettyShortenNumber = (number: number): string => { export const prettyShortenNumber = (number: number): string => {
if (number > 1000000) { if (number > 1_000_000) {
return number.toLocaleString(undefined, { return `${(number / 1_000_000).toLocaleString(undefined, {
maximumSignificantDigits: 3, maximumSignificantDigits: 3,
}); })}M`;
} else if (number > 1000) { } else if (number > 1_000) {
return `${(number / 1000).toLocaleString(undefined, { return `${(number / 1_000).toLocaleString(undefined, {
maximumSignificantDigits: 2, maximumSignificantDigits: 2,
})}K`; })}K`;
} }

48
src/helpers/search.ts Normal file
View File

@ -0,0 +1,48 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// eslint-disable-next-line import/named
import { MatchesPosition, MeiliSearch, SearchParams, SearchResponse } from "meilisearch";
import { isDefined } from "./asserts";
import { MeiliDocumentsType } from "shared/meilisearch-graphql-typings/meiliTypes";
const meili = new MeiliSearch({
host: process.env.NEXT_PUBLIC_URL_MEILISEARCH ?? "",
apiKey: process.env.NEXT_PUBLIC_MEILISEARCH_KEY,
});
interface CustomSearchParams
extends Omit<
SearchParams,
"cropMarker" | "highlightPostTag" | "highlightPreTag" | "q" | "showMatchesPosition"
> {}
type CustomHit<T = Record<string, any>> = T & {
_formatted: Partial<T>;
_matchesPosition: MatchesPosition<T>;
};
type CustomHits<T = Record<string, any>> = CustomHit<T>[];
export interface CustomSearchResponse<T> extends Omit<SearchResponse<T>, "hits"> {
hits: CustomHits<T>;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const meiliSearch = async <I extends MeiliDocumentsType["index"]>(
indexName: I,
query: string,
options: CustomSearchParams
) => {
const index = meili.index(indexName);
return (await index.search<Extract<MeiliDocumentsType, { index: I }>["documents"]>(query, {
...options,
attributesToHighlight: options.attributesToHighlight ?? ["*"],
highlightPreTag: "<mark>",
highlightPostTag: "</mark>",
showMatchesPosition: true,
cropLength: 20,
cropMarker: "...",
})) as unknown as CustomSearchResponse<Extract<MeiliDocumentsType, { index: I }>["documents"]>;
};
export const containsHighlight = (text: string | null | undefined): boolean =>
isDefined(text) && text.includes("</mark>");

View File

@ -0,0 +1,36 @@
import { NextRouter, useRouter } from "next/router";
import { useCallback } from "react";
import { z } from "zod";
interface TypeRouter<T extends z.Schema> extends Omit<NextRouter, "query"> {
query: z.TypeOf<T>;
updateQuery: (queryParams: z.TypeOf<T>) => void;
}
export const useTypedRouter = <T extends z.Schema>(schema: T): TypeRouter<T> => {
const { query, ...router } = useRouter();
const updateQuery = useCallback(
async (queryParams: z.TypeOf<T>) => {
Object.keys(queryParams).map((key: keyof typeof queryParams) => {
if (typeof queryParams[key] === "boolean") {
queryParams[key] = queryParams[key] ? "true" : undefined;
}
});
await router.replace(
{ pathname: router.pathname, query: { ...query, ...queryParams } },
undefined,
{
shallow: true,
}
);
},
[router, query]
);
return {
query: schema.parse(query) as z.infer<typeof schema>,
updateQuery,
...router,
};
};

View File

@ -1,5 +1,4 @@
import "@fontsource/material-icons"; import "material-symbols/rounded.css";
import "@fontsource/material-icons-outlined";
import "@fontsource/opendyslexic/400.css"; import "@fontsource/opendyslexic/400.css";
import "@fontsource/share-tech-mono/400.css"; import "@fontsource/share-tech-mono/400.css";
import "@fontsource/opendyslexic/700.css"; import "@fontsource/opendyslexic/700.css";
@ -23,6 +22,7 @@ import { SettingsPopup } from "components/Panels/SettingsPopup";
import { useSettings } from "contexts/settings"; import { useSettings } from "contexts/settings";
import { useContainerQueries } from "contexts/containerQueries"; import { useContainerQueries } from "contexts/containerQueries";
import { useWebkitFixes } from "contexts/webkitFixes"; import { useWebkitFixes } from "contexts/webkitFixes";
import { SearchPopup } from "components/Panels/SearchPopup";
const AccordsLibraryApp = (props: AppProps): JSX.Element => { const AccordsLibraryApp = (props: AppProps): JSX.Element => {
useLocalData(); useLocalData();
@ -33,6 +33,7 @@ const AccordsLibraryApp = (props: AppProps): JSX.Element => {
return ( return (
<> <>
<SearchPopup />
<SettingsPopup /> <SettingsPopup />
<LightBoxProvider /> <LightBoxProvider />
<Script <Script

View File

@ -1,6 +1,5 @@
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Icon } from "components/Ico";
import { NavOption } from "components/PanelComponents/NavOption"; import { NavOption } from "components/PanelComponents/NavOption";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
@ -24,7 +23,7 @@ const AboutUs = (props: Props): JSX.Element => {
subPanel={ subPanel={
<SubPanel> <SubPanel>
<PanelHeader <PanelHeader
icon={Icon.Info} icon="info"
title={langui.about_us} title={langui.about_us}
description={langui.about_us_description} description={langui.about_us_description}
/> />

View File

@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { i18n } from "../../../next.config"; import { i18n } from "../../../next.config";
import { cartesianProduct } from "helpers/others"; import { cartesianProduct } from "helpers/others";
import { filterHasAttributes, isDefined } from "helpers/asserts"; import { filterHasAttributes } from "helpers/asserts";
import { fetchLocalData } from "graphql/fetchLocalData"; import { fetchLocalData } from "graphql/fetchLocalData";
import { getReadySdk } from "graphql/sdk"; import { getReadySdk } from "graphql/sdk";
@ -169,13 +169,11 @@ const Revalidate = async (
switch (body.model) { switch (body.model) {
case "post": { case "post": {
paths.push(`/news`);
paths.push(`/news/${body.entry.slug}`); paths.push(`/news/${body.entry.slug}`);
break; break;
} }
case "library-item": { case "library-item": {
paths.push(`/library`);
paths.push(`/library/${body.entry.slug}`); paths.push(`/library/${body.entry.slug}`);
paths.push(`/library/${body.entry.slug}/reader`); paths.push(`/library/${body.entry.slug}/reader`);
@ -193,8 +191,6 @@ const Revalidate = async (
} }
case "content": { case "content": {
paths.push(`/contents`);
paths.push(`/contents/all`);
paths.push(`/contents/${body.entry.slug}`); paths.push(`/contents/${body.entry.slug}`);
if (body.entry.folder.count > 0 || body.entry.ranged_contents.count > 0) { if (body.entry.folder.count > 0 || body.entry.ranged_contents.count > 0) {
@ -205,7 +201,11 @@ const Revalidate = async (
const folderSlug = content.contents?.data[0]?.attributes?.folder?.data?.attributes?.slug; const folderSlug = content.contents?.data[0]?.attributes?.folder?.data?.attributes?.slug;
if (folderSlug) { if (folderSlug) {
paths.push(`/contents/folder/${folderSlug}`); if (folderSlug === "root") {
paths.push(`/contents`);
} else {
paths.push(`/contents/folder/${folderSlug}`);
}
} }
filterHasAttributes(content.contents?.data[0]?.attributes?.ranged_contents?.data, [ filterHasAttributes(content.contents?.data[0]?.attributes?.ranged_contents?.data, [
@ -248,8 +248,9 @@ const Revalidate = async (
case "contents-folder": { case "contents-folder": {
if (body.entry.slug === "root") { if (body.entry.slug === "root") {
paths.push(`/contents`); paths.push(`/contents`);
} else {
paths.push(`/contents/folder/${body.entry.slug}`);
} }
paths.push(`/contents/folder/${body.entry.slug}`);
if ( if (
body.entry.contents.count > 0 || body.entry.contents.count > 0 ||
@ -278,7 +279,6 @@ const Revalidate = async (
} }
case "wiki-page": { case "wiki-page": {
paths.push(`/wiki`);
paths.push(`/wiki/${body.entry.slug}`); paths.push(`/wiki/${body.entry.slug}`);
break; break;
} }
@ -306,13 +306,7 @@ const Revalidate = async (
case "video": { case "video": {
if (body.entry.uid) { if (body.entry.uid) {
paths.push(`/archives/videos`);
paths.push(`/archives/videos/v/${body.entry.uid}`); paths.push(`/archives/videos/v/${body.entry.uid}`);
const video = await sdk.getVideo({ uid: body.entry.uid });
const channelUid = video.videos?.data[0]?.attributes?.channel?.data?.attributes?.uid;
if (isDefined(channelUid)) {
paths.push(`/archives/videos/c/${channelUid}`);
}
} }
break; break;
} }

View File

@ -3,7 +3,6 @@ import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { NavOption } from "components/PanelComponents/NavOption"; import { NavOption } from "components/PanelComponents/NavOption";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { Icon } from "components/Ico";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
@ -21,11 +20,7 @@ const Archives = (props: Props): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
<PanelHeader <PanelHeader icon="save" title={langui.archives} description={langui.archives_description} />
icon={Icon.Inventory}
title={langui.archives}
description={langui.archives_description}
/>
<HorizontalLine /> <HorizontalLine />
<NavOption title={"Videos"} url="/archives/videos/" border /> <NavOption title={"Videos"} url="/archives/videos/" border />
</SubPanel> </SubPanel>

View File

@ -1,29 +1,33 @@
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next"; import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { z } from "zod";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
import { TextInput } from "components/Inputs/TextInput";
import { WithLabel } from "components/Inputs/WithLabel";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { ReturnButton } from "components/PanelComponents/ReturnButton"; import { ReturnButton } from "components/PanelComponents/ReturnButton";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { PreviewCard } from "components/PreviewCard";
import { GetVideoChannelQuery } from "graphql/generated";
import { getReadySdk } from "graphql/sdk";
import { getVideoThumbnailURL } from "helpers/videos";
import { Icon } from "components/Ico";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { WithLabel } from "components/Inputs/WithLabel";
import { filterHasAttributes, isDefined } from "helpers/asserts";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { compareDate } from "helpers/date";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { SmartList } from "components/SmartList";
import { cIf } from "helpers/className";
import { TextInput } from "components/Inputs/TextInput";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { CustomSearchResponse, meiliSearch } from "helpers/search";
import { MeiliIndices, MeiliVideo } from "shared/meilisearch-graphql-typings/meiliTypes";
import { PreviewCard } from "components/PreviewCard";
import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { getVideoThumbnailURL } from "helpers/videos";
import { useTypedRouter } from "hooks/useTypedRouter";
import { Select } from "components/Inputs/Select";
import { sendAnalytics } from "helpers/analytics";
import { Button } from "components/Inputs/Button";
import { GetVideoChannelQuery } from "graphql/generated";
import { getReadySdk } from "graphql/sdk";
import { Paginator } from "components/Containers/Paginator";
/* /*
* *
@ -32,95 +36,240 @@ import { useAtomGetter } from "helpers/atoms";
const DEFAULT_FILTERS_STATE = { const DEFAULT_FILTERS_STATE = {
searchName: "", searchName: "",
page: 1,
sortingMethod: 1,
onlyShowGone: false,
keepInfoVisible: true,
}; };
const queryParamSchema = z.object({
query: z.coerce.string().optional(),
page: z.coerce.number().positive().optional(),
sort: z.coerce.number().min(0).max(5).optional(),
gone: z.coerce.boolean().optional(),
});
/* /*
* *
* PAGE * PAGE
*/ */
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {
channel: NonNullable<GetVideoChannelQuery["videoChannels"]>["data"][number]["attributes"]; channel: NonNullable<
NonNullable<GetVideoChannelQuery["videoChannels"]>["data"][number]["attributes"]
>;
} }
const Channel = ({ channel, ...otherProps }: Props): JSX.Element => { const Channel = ({ channel, ...otherProps }: Props): JSX.Element => {
const { value: keepInfoVisible, toggle: toggleKeepInfoVisible } = useBoolean(true);
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
const hoverable = useDeviceSupportsHover(); const hoverable = useDeviceSupportsHover();
const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl); const router = useTypedRouter(queryParamSchema);
const [searchName, setSearchName] = useState(DEFAULT_FILTERS_STATE.searchName); const sortingMethods = useMemo(
() => [
{ meiliAttribute: "sortable_published_date:asc", displayedName: langui.oldest },
{ meiliAttribute: "sortable_published_date:desc", displayedName: langui.newest },
{ meiliAttribute: "views:asc", displayedName: langui.least_popular },
{ meiliAttribute: "views:desc", displayedName: langui.most_popular },
{ meiliAttribute: "duration:asc", displayedName: langui.shortest },
{ meiliAttribute: "duration:desc", displayedName: langui.longest },
],
[
langui.least_popular,
langui.longest,
langui.most_popular,
langui.newest,
langui.oldest,
langui.shortest,
]
);
const {
value: keepInfoVisible,
toggle: toggleKeepInfoVisible,
setValue: setKeepInfoVisible,
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
const {
value: onlyShowGone,
toggle: toggleOnlyShowGone,
setValue: setOnlyShowGone,
} = useBoolean(router.query.gone ?? DEFAULT_FILTERS_STATE.onlyShowGone);
const [query, setQuery] = useState<string>(
router.query.query ?? DEFAULT_FILTERS_STATE.searchName
);
const [page, setPage] = useState<number>(router.query.page ?? DEFAULT_FILTERS_STATE.page);
const [sortingMethod, setSortingMethod] = useState<number>(
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
);
const [videos, setVideos] = useState<CustomSearchResponse<MeiliVideo>>();
useEffect(() => {
const fetchVideos = async () => {
const currentSortingMethod = sortingMethods[sortingMethod];
const searchResult = await meiliSearch(MeiliIndices.VIDEOS, query, {
hitsPerPage: 25,
page,
attributesToRetrieve: [
"title",
"channel",
"uid",
"published_date",
"views",
"duration",
"description",
],
attributesToHighlight: ["title", "channel", "description"],
attributesToCrop: ["description"],
sort: isDefined(currentSortingMethod) ? [currentSortingMethod.meiliAttribute] : undefined,
filter: [onlyShowGone ? "gone = true" : "", `channel_uid = ${channel.uid}`],
});
setVideos(searchResult);
};
fetchVideos();
}, [query, page, sortingMethod, onlyShowGone, channel, sortingMethods]);
useEffect(() => {
if (router.isReady)
router.updateQuery({
page,
query,
sort: sortingMethod,
gone: onlyShowGone,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, query, sortingMethod, onlyShowGone, router.isReady]);
useEffect(() => {
if (router.isReady) {
if (isDefined(router.query.page)) setPage(router.query.page);
if (isDefined(router.query.query)) setQuery(router.query.query);
if (isDefined(router.query.sort)) setSortingMethod(router.query.sort);
if (isDefined(router.query.gone)) setOnlyShowGone(router.query.gone);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]);
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
<ReturnButton <ReturnButton
href="/archives/videos/" href="/archives/videos"
title={langui.videos} title={langui.videos}
displayOnlyOn={"3ColumnsLayout"} displayOnlyOn={"3ColumnsLayout"}
className="mb-10" className="mb-10"
/> />
<PanelHeader <PanelHeader
icon={Icon.Movie} icon="movie"
title={langui.videos} title={channel.title}
description={langui.archives_description} description={`${channel.subscribers.toLocaleString()} ${langui.subscribers?.toLowerCase()}`}
/> />
<HorizontalLine /> <HorizontalLine />
<TextInput <TextInput
className="mb-6 w-full" className="mb-6 w-full"
placeholder={langui.search_title ?? "Search title..."} placeholder={langui.search_title}
value={searchName} value={query}
onChange={setSearchName} onChange={(newQuery) => {
setPage(1);
setQuery(newQuery);
if (isDefinedAndNotEmpty(newQuery)) {
sendAnalytics("Videos", "Change search term");
} else {
sendAnalytics("Videos", "Clear search term");
}
}}
/> />
<WithLabel label={langui.order_by}>
<Select
className="w-full"
options={sortingMethods.map((item) => item.displayedName ?? "")}
value={sortingMethod}
onChange={(newSort) => {
setPage(1);
setSortingMethod(newSort);
sendAnalytics(
"Videos",
`Change sorting method (${sortingMethods.map((item) => item.displayedName)[newSort]})`
);
}}
/>
</WithLabel>
<WithLabel label={langui.only_unavailable_videos}>
<Switch
value={onlyShowGone}
onClick={() => {
toggleOnlyShowGone();
}}
/>
</WithLabel>
{hoverable && ( {hoverable && (
<WithLabel label={langui.always_show_info}> <WithLabel label={langui.always_show_info}>
<Switch value={keepInfoVisible} onClick={toggleKeepInfoVisible} /> <Switch value={keepInfoVisible} onClick={toggleKeepInfoVisible} />
</WithLabel> </WithLabel>
)} )}
<Button
className="mt-8"
text={langui.reset_all_filters}
icon="settings_backup_restore"
onClick={() => {
setOnlyShowGone(DEFAULT_FILTERS_STATE.onlyShowGone);
setPage(DEFAULT_FILTERS_STATE.page);
setQuery(DEFAULT_FILTERS_STATE.searchName);
setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod);
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
sendAnalytics("Videos", "Reset all filters");
}}
/>
</SubPanel> </SubPanel>
); );
const contentPanel = ( const contentPanel = (
<ContentPanel width={ContentPanelWidthSizes.Full}> <ContentPanel width={ContentPanelWidthSizes.Full}>
<SmartList <Paginator page={page} onPageChange={setPage} totalNumberOfPages={videos?.totalPages}>
items={filterHasAttributes(channel?.videos?.data, ["id", "attributes"] as const)} <div
getItemId={(item) => item.id} className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
renderItem={({ item }) => ( gap-x-6 gap-y-8">
<PreviewCard {videos?.hits.map((item) => (
href={`/archives/videos/v/${item.attributes.uid}`} <PreviewCard
title={item.attributes.title} key={item.uid}
thumbnail={getVideoThumbnailURL(item.attributes.uid)} href={`/archives/videos/v/${item.uid}`}
thumbnailAspectRatio="16/9" title={item._formatted.title}
thumbnailForceAspectRatio thumbnail={getVideoThumbnailURL(item.uid)}
keepInfoVisible={keepInfoVisible} thumbnailAspectRatio="16/9"
metadata={{ thumbnailForceAspectRatio
releaseDate: item.attributes.published_date, keepInfoVisible={keepInfoVisible}
views: item.attributes.views, metadata={{
author: channel?.title, releaseDate: item.published_date,
position: "Top", views: item.views,
}} author: item._formatted.channel?.data?.attributes?.title,
hoverlay={{ position: "Top",
__typename: "Video", }}
duration: item.attributes.duration, description={
}} item._matchesPosition.description && item._matchesPosition.description.length > 0
/> ? item._formatted.description
)} : undefined
className={cIf( }
isContentPanelAtLeast4xl, hoverlay={{
"grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] gap-x-6 gap-y-8", __typename: "Video",
"grid-cols-2 gap-x-3 gap-y-5" duration: item.duration,
)} }}
groupingFunction={() => [channel?.title ?? ""]} />
paginationItemPerPage={25} ))}
searchingTerm={searchName} </div>
searchingBy={(item) => item.attributes.title} </Paginator>
/>
</ContentPanel> </ContentPanel>
); );
return <AppLayout subPanel={subPanel} contentPanel={contentPanel} {...otherProps} />; return <AppLayout subPanel={subPanel} contentPanel={contentPanel} {...otherProps} />;
}; };
export default Channel; export default Channel;
@ -138,10 +287,6 @@ export const getStaticProps: GetStaticProps = async (context) => {
}); });
if (!channel.videoChannels?.data[0]?.attributes) return { notFound: true }; if (!channel.videoChannels?.data[0]?.attributes) return { notFound: true };
channel.videoChannels.data[0].attributes.videos?.data
.sort((a, b) => compareDate(a.attributes?.published_date, b.attributes?.published_date))
.reverse();
const props: Props = { const props: Props = {
channel: channel.videoChannels.data[0].attributes, channel: channel.videoChannels.data[0].attributes,
openGraph: getOpenGraph(langui, channel.videoChannels.data[0].attributes.title), openGraph: getOpenGraph(langui, channel.videoChannels.data[0].attributes.title),

View File

@ -1,9 +1,8 @@
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { z } from "zod";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { SmartList } from "components/SmartList";
import { Icon } from "components/Ico";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
import { TextInput } from "components/Inputs/TextInput"; import { TextInput } from "components/Inputs/TextInput";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
@ -11,19 +10,22 @@ import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { ReturnButton } from "components/PanelComponents/ReturnButton"; import { ReturnButton } from "components/PanelComponents/ReturnButton";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { PreviewCard } from "components/PreviewCard";
import { GetVideosPreviewQuery } from "graphql/generated";
import { getReadySdk } from "graphql/sdk";
import { filterHasAttributes } from "helpers/asserts";
import { getVideoThumbnailURL } from "helpers/videos";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { compareDate } from "helpers/date";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { cIf } from "helpers/className";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { CustomSearchResponse, meiliSearch } from "helpers/search";
import { MeiliIndices, MeiliVideo } from "shared/meilisearch-graphql-typings/meiliTypes";
import { PreviewCard } from "components/PreviewCard";
import { isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
import { getVideoThumbnailURL } from "helpers/videos";
import { useTypedRouter } from "hooks/useTypedRouter";
import { Select } from "components/Inputs/Select";
import { sendAnalytics } from "helpers/analytics";
import { Button } from "components/Inputs/Button";
import { Paginator } from "components/Containers/Paginator";
/* /*
* *
@ -32,25 +34,120 @@ import { useAtomGetter } from "helpers/atoms";
const DEFAULT_FILTERS_STATE = { const DEFAULT_FILTERS_STATE = {
searchName: "", searchName: "",
page: 1,
sortingMethod: 1,
onlyShowGone: false,
keepInfoVisible: true,
}; };
const queryParamSchema = z.object({
query: z.coerce.string().optional(),
page: z.coerce.number().positive().optional(),
sort: z.coerce.number().min(0).max(5).optional(),
gone: z.coerce.boolean().optional(),
});
/* /*
* *
* PAGE * PAGE
*/ */
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {}
videos: NonNullable<GetVideosPreviewQuery["videos"]>["data"];
}
const Videos = ({ videos, ...otherProps }: Props): JSX.Element => { const Videos = ({ ...otherProps }: Props): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
const hoverable = useDeviceSupportsHover(); const hoverable = useDeviceSupportsHover();
const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl); const router = useTypedRouter(queryParamSchema);
const { value: keepInfoVisible, toggle: toggleKeepInfoVisible } = useBoolean(true); const sortingMethods = useMemo(
() => [
{ meiliAttribute: "sortable_published_date:asc", displayedName: langui.oldest },
{ meiliAttribute: "sortable_published_date:desc", displayedName: langui.newest },
{ meiliAttribute: "views:asc", displayedName: langui.least_popular },
{ meiliAttribute: "views:desc", displayedName: langui.most_popular },
{ meiliAttribute: "duration:asc", displayedName: langui.shortest },
{ meiliAttribute: "duration:desc", displayedName: langui.longest },
],
[
langui.least_popular,
langui.longest,
langui.most_popular,
langui.newest,
langui.oldest,
langui.shortest,
]
);
const [searchName, setSearchName] = useState(DEFAULT_FILTERS_STATE.searchName); const {
value: keepInfoVisible,
toggle: toggleKeepInfoVisible,
setValue: setKeepInfoVisible,
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
const {
value: onlyShowGone,
toggle: toggleOnlyShowGone,
setValue: setOnlyShowGone,
} = useBoolean(router.query.gone ?? DEFAULT_FILTERS_STATE.onlyShowGone);
const [query, setQuery] = useState<string>(
router.query.query ?? DEFAULT_FILTERS_STATE.searchName
);
const [page, setPage] = useState<number>(router.query.page ?? DEFAULT_FILTERS_STATE.page);
const [sortingMethod, setSortingMethod] = useState<number>(
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
);
const [videos, setVideos] = useState<CustomSearchResponse<MeiliVideo>>();
useEffect(() => {
const fetchVideos = async () => {
const currentSortingMethod = sortingMethods[sortingMethod];
const searchResult = await meiliSearch(MeiliIndices.VIDEOS, query, {
hitsPerPage: 25,
page,
attributesToRetrieve: [
"title",
"channel",
"uid",
"published_date",
"views",
"duration",
"description",
],
attributesToHighlight: ["title", "channel", "description"],
attributesToCrop: ["description"],
sort: isDefined(currentSortingMethod) ? [currentSortingMethod.meiliAttribute] : undefined,
filter: onlyShowGone ? ["gone = true"] : undefined,
});
setVideos(searchResult);
};
fetchVideos();
}, [query, page, sortingMethod, onlyShowGone, sortingMethods]);
useEffect(() => {
if (router.isReady)
router.updateQuery({
page,
query,
sort: sortingMethod,
gone: onlyShowGone,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, query, sortingMethod, onlyShowGone, router.isReady]);
useEffect(() => {
if (router.isReady) {
if (isDefined(router.query.page)) setPage(router.query.page);
if (isDefined(router.query.query)) setQuery(router.query.query);
if (isDefined(router.query.sort)) setSortingMethod(router.query.sort);
if (isDefined(router.query.gone)) setOnlyShowGone(router.query.gone);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]);
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
@ -61,59 +158,107 @@ const Videos = ({ videos, ...otherProps }: Props): JSX.Element => {
className="mb-10" className="mb-10"
/> />
<PanelHeader icon={Icon.Movie} title="Videos" description={langui.archives_description} /> <PanelHeader icon="movie" title={langui.videos} description={langui.archives_description} />
<HorizontalLine /> <HorizontalLine />
<TextInput <TextInput
className="mb-6 w-full" className="mb-6 w-full"
placeholder={langui.search_title ?? "Search title..."} placeholder={langui.search_title}
value={searchName} value={query}
onChange={setSearchName} onChange={(newQuery) => {
setPage(1);
setQuery(newQuery);
if (isDefinedAndNotEmpty(newQuery)) {
sendAnalytics("Videos", "Change search term");
} else {
sendAnalytics("Videos", "Clear search term");
}
}}
/> />
<WithLabel label={langui.order_by}>
<Select
className="w-full"
options={sortingMethods.map((item) => item.displayedName ?? "")}
value={sortingMethod}
onChange={(newSort) => {
setPage(1);
setSortingMethod(newSort);
sendAnalytics(
"Videos",
`Change sorting method (${sortingMethods.map((item) => item.displayedName)[newSort]})`
);
}}
/>
</WithLabel>
<WithLabel label={langui.only_unavailable_videos}>
<Switch
value={onlyShowGone}
onClick={() => {
toggleOnlyShowGone();
}}
/>
</WithLabel>
{hoverable && ( {hoverable && (
<WithLabel label={langui.always_show_info}> <WithLabel label={langui.always_show_info}>
<Switch value={keepInfoVisible} onClick={toggleKeepInfoVisible} /> <Switch value={keepInfoVisible} onClick={toggleKeepInfoVisible} />
</WithLabel> </WithLabel>
)} )}
<Button
className="mt-8"
text={langui.reset_all_filters}
icon="settings_backup_restore"
onClick={() => {
setPage(1);
setOnlyShowGone(DEFAULT_FILTERS_STATE.onlyShowGone);
setPage(DEFAULT_FILTERS_STATE.page);
setQuery(DEFAULT_FILTERS_STATE.searchName);
setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod);
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
sendAnalytics("Videos", "Reset all filters");
}}
/>
</SubPanel> </SubPanel>
); );
const contentPanel = ( const contentPanel = (
<ContentPanel width={ContentPanelWidthSizes.Full}> <ContentPanel width={ContentPanelWidthSizes.Full}>
<SmartList <Paginator page={page} onPageChange={setPage} totalNumberOfPages={videos?.totalPages}>
items={filterHasAttributes(videos, ["id", "attributes"] as const)} <div
getItemId={(item) => item.id} className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
renderItem={({ item }) => ( gap-x-6 gap-y-8">
<PreviewCard {videos?.hits.map((item) => (
href={`/archives/videos/v/${item.attributes.uid}`} <PreviewCard
title={item.attributes.title} key={item.uid}
thumbnail={getVideoThumbnailURL(item.attributes.uid)} href={`/archives/videos/v/${item.uid}`}
thumbnailAspectRatio="16/9" title={item._formatted.title}
thumbnailForceAspectRatio thumbnail={getVideoThumbnailURL(item.uid)}
keepInfoVisible={keepInfoVisible} thumbnailAspectRatio="16/9"
metadata={{ thumbnailForceAspectRatio
releaseDate: item.attributes.published_date, keepInfoVisible={keepInfoVisible}
views: item.attributes.views, metadata={{
author: item.attributes.channel?.data?.attributes?.title, releaseDate: item.published_date,
position: "Top", views: item.views,
}} author: item._formatted.channel?.data?.attributes?.title,
hoverlay={{ position: "Top",
__typename: "Video", }}
duration: item.attributes.duration, description={
}} item._matchesPosition.description && item._matchesPosition.description.length > 0
/> ? item._formatted.description
)} : undefined
className={cIf( }
isContentPanelAtLeast4xl, hoverlay={{
"grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] gap-x-6 gap-y-8", __typename: "Video",
"grid-cols-2 gap-x-3 gap-y-5" duration: item.duration,
)} }}
paginationItemPerPage={25} />
searchingTerm={searchName} ))}
searchingBy={(item) => item.attributes.title} </div>
/> </Paginator>
</ContentPanel> </ContentPanel>
); );
return <AppLayout subPanel={subPanel} contentPanel={contentPanel} {...otherProps} />; return <AppLayout subPanel={subPanel} contentPanel={contentPanel} {...otherProps} />;
@ -125,17 +270,9 @@ export default Videos;
* NEXT DATA FETCHING * NEXT DATA FETCHING
*/ */
export const getStaticProps: GetStaticProps = async (context) => { export const getStaticProps: GetStaticProps = (context) => {
const sdk = getReadySdk();
const langui = getLangui(context.locale); const langui = getLangui(context.locale);
const videos = await sdk.getVideosPreview();
if (!videos.videos) return { notFound: true };
videos.videos.data
.sort((a, b) => compareDate(a.attributes?.published_date, b.attributes?.published_date))
.reverse();
const props: Props = { const props: Props = {
videos: videos.videos.data,
openGraph: getOpenGraph(langui, langui.videos ?? "Videos"), openGraph: getOpenGraph(langui, langui.videos ?? "Videos"),
}; };
return { return {

View File

@ -2,7 +2,7 @@ import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { InsetBox } from "components/Containers/InsetBox"; import { InsetBox } from "components/Containers/InsetBox";
import { NavOption } from "components/PanelComponents/NavOption"; import { NavOption } from "components/PanelComponents/NavOption";
@ -79,18 +79,18 @@ const Video = ({ video, ...otherProps }: Props): JSX.Element => {
<h1 className="text-2xl">{video.title}</h1> <h1 className="text-2xl">{video.title}</h1>
<div className="flex w-full flex-row flex-wrap gap-x-6"> <div className="flex w-full flex-row flex-wrap gap-x-6">
<p> <p>
<Ico icon={Icon.Event} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="event" className="mr-1 translate-y-[.15em] !text-base" />
{prettyDate(video.published_date, router.locale)} {prettyDate(video.published_date, router.locale)}
</p> </p>
<p> <p>
<Ico icon={Icon.Visibility} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="visibility" className="mr-1 translate-y-[.15em] !text-base" />
{isContentPanelAtLeast4xl {isContentPanelAtLeast4xl
? video.views.toLocaleString() ? video.views.toLocaleString()
: prettyShortenNumber(video.views)} : prettyShortenNumber(video.views)}
</p> </p>
{video.channel?.data?.attributes && ( {video.channel?.data?.attributes && (
<p> <p>
<Ico icon={Icon.ThumbUp} className="mr-1 translate-y-[.15em] !text-base" /> <Ico icon="thumb_up" className="mr-1 translate-y-[.15em] !text-base" />
{isContentPanelAtLeast4xl {isContentPanelAtLeast4xl
? video.likes.toLocaleString() ? video.likes.toLocaleString()
: prettyShortenNumber(video.likes)} : prettyShortenNumber(video.likes)}
@ -109,7 +109,8 @@ const Video = ({ video, ...otherProps }: Props): JSX.Element => {
<h2 className="text-2xl">{langui.channel}</h2> <h2 className="text-2xl">{langui.channel}</h2>
<div> <div>
<Button <Button
href={`/archives/videos/c/${video.channel.data.attributes.uid}`} href={`/archives/videos/c/${video.channel.data.attributes.uid}\
?page=1&query=&sort=1&gone=`}
text={video.channel.data.attributes.title} text={video.channel.data.attributes.title}
/> />
<p> <p>

View File

@ -13,7 +13,6 @@ import { HorizontalLine } from "components/HorizontalLine";
import { GetChroniclesChaptersQuery } from "graphql/generated"; import { GetChroniclesChaptersQuery } from "graphql/generated";
import { prettyInlineTitle, prettySlug } from "helpers/formatters"; import { prettyInlineTitle, prettySlug } from "helpers/formatters";
import { ReturnButton } from "components/PanelComponents/ReturnButton"; import { ReturnButton } from "components/PanelComponents/ReturnButton";
import { Icon } from "components/Ico";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales"; import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales";
import { getDescription } from "helpers/description"; import { getDescription } from "helpers/description";
@ -21,6 +20,8 @@ import { TranslatedChroniclesList } from "components/Chronicles/ChroniclesList";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { Ids } from "types/ids";
/* /*
* *
@ -34,6 +35,8 @@ interface Props extends AppLayoutRequired {
const Chronicle = ({ chronicle, chapters, ...otherProps }: Props): JSX.Element => { const Chronicle = ({ chronicle, chapters, ...otherProps }: Props): JSX.Element => {
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
useScrollTopOnChange(Ids.ContentPanel, [chronicle.slug]);
const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({ const [selectedTranslation, LanguageSwitcher, languageSwitcherProps] = useSmartLanguage({
items: chronicle.translations, items: chronicle.translations,
languageExtractor: useCallback( languageExtractor: useCallback(
@ -143,7 +146,7 @@ const Chronicle = ({ chronicle, chapters, ...otherProps }: Props): JSX.Element =
<AppLayout <AppLayout
contentPanel={contentPanel} contentPanel={contentPanel}
subPanel={subPanel} subPanel={subPanel}
subPanelIcon={Icon.FormatListNumbered} subPanelIcon="format_list_numbered"
{...otherProps} {...otherProps}
/> />
); );

View File

@ -2,7 +2,6 @@ import { GetStaticProps } from "next";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { Icon } from "components/Ico";
import { getReadySdk } from "graphql/sdk"; import { getReadySdk } from "graphql/sdk";
import { GetChroniclesChaptersQuery } from "graphql/generated"; import { GetChroniclesChaptersQuery } from "graphql/generated";
import { filterHasAttributes } from "helpers/asserts"; import { filterHasAttributes } from "helpers/asserts";
@ -28,7 +27,7 @@ const Chronicles = ({ chapters, ...otherProps }: Props): JSX.Element => {
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
<PanelHeader <PanelHeader
icon={Icon.WatchLater} icon="schedule"
title={langui.chronicles} title={langui.chronicles}
description={langui.chronicles_description} description={langui.chronicles_description}
/> />

View File

@ -1,36 +1,35 @@
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import { useState, useCallback } from "react"; import { useEffect, useMemo, useState } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import naturalCompare from "string-natural-compare"; import { z } from "zod";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Select } from "components/Inputs/Select"; import { Select } from "components/Inputs/Select";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { getReadySdk } from "graphql/sdk";
import { prettyInlineTitle, prettySlug } from "helpers/formatters";
import { TextInput } from "components/Inputs/TextInput"; import { TextInput } from "components/Inputs/TextInput";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { Icon } from "components/Ico";
import { import {
filterDefined, filterDefined,
filterHasAttributes, filterHasAttributes,
isDefined,
isDefinedAndNotEmpty, isDefinedAndNotEmpty,
SelectiveNonNullable,
} from "helpers/asserts"; } from "helpers/asserts";
import { GetContentsQuery } from "graphql/generated";
import { SmartList } from "components/SmartList";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { TranslatedPreviewCard } from "components/PreviewCard";
import { cJoin, cIf } from "helpers/className";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
import { sendAnalytics } from "helpers/analytics"; import { sendAnalytics } from "helpers/analytics";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { containsHighlight, CustomSearchResponse, meiliSearch } from "helpers/search";
import { MeiliContent, MeiliIndices } from "shared/meilisearch-graphql-typings/meiliTypes";
import { useTypedRouter } from "hooks/useTypedRouter";
import { TranslatedPreviewCard } from "components/PreviewCard";
import { prettySlug } from "helpers/formatters";
import { Paginator } from "components/Containers/Paginator";
/* /*
* *
@ -38,108 +37,117 @@ import { useAtomGetter } from "helpers/atoms";
*/ */
const DEFAULT_FILTERS_STATE = { const DEFAULT_FILTERS_STATE = {
groupingMethod: -1, sortingMethod: 0,
keepInfoVisible: false, keepInfoVisible: true,
searchName: "", query: "",
page: 1,
}; };
const queryParamSchema = z.object({
query: z.coerce.string().optional(),
page: z.coerce.number().positive().optional(),
sort: z.coerce.number().min(0).max(5).optional(),
});
/* /*
* *
* PAGE * PAGE
*/ */
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {}
contents: NonNullable<GetContentsQuery["contents"]>["data"];
}
const Contents = ({ contents, ...otherProps }: Props): JSX.Element => { const Contents = (props: Props): JSX.Element => {
const hoverable = useDeviceSupportsHover(); const hoverable = useDeviceSupportsHover();
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl); const router = useTypedRouter(queryParamSchema);
const [groupingMethod, setGroupingMethod] = useState<number>( const sortingMethods = useMemo(
DEFAULT_FILTERS_STATE.groupingMethod () => [
{ meiliAttribute: "slug:asc", displayedName: langui.name },
{ meiliAttribute: "sortable_updated_date:asc", displayedName: langui.oldest },
{ meiliAttribute: "sortable_updated_date:desc", displayedName: langui.newest },
],
[langui.name, langui.newest, langui.oldest]
); );
const [sortingMethod, setSortingMethod] = useState<number>(
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
);
const { const {
value: keepInfoVisible, value: keepInfoVisible,
toggle: toggleKeepInfoVisible, toggle: toggleKeepInfoVisible,
setValue: setKeepInfoVisible, setValue: setKeepInfoVisible,
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible); } = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
const [searchName, setSearchName] = useState(DEFAULT_FILTERS_STATE.searchName); const [page, setPage] = useState<number>(router.query.page ?? DEFAULT_FILTERS_STATE.page);
const [contents, setContents] = useState<CustomSearchResponse<MeiliContent>>();
const [query, setQuery] = useState(router.query.query ?? DEFAULT_FILTERS_STATE.query);
const groupingFunction = useCallback( useEffect(() => {
( const fetchPosts = async () => {
item: SelectiveNonNullable< const currentSortingMethod = sortingMethods[sortingMethod];
NonNullable<GetContentsQuery["contents"]>["data"][number], const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, {
"attributes" | "id" attributesToRetrieve: ["translations", "id", "slug", "categories", "type", "thumbnail"],
> attributesToHighlight: ["translations"],
): string[] => { attributesToCrop: ["translations.displayable_description"],
switch (groupingMethod) { hitsPerPage: 25,
case 0: { page,
const categories = filterHasAttributes(item.attributes.categories?.data, [ sort: isDefined(currentSortingMethod) ? [currentSortingMethod.meiliAttribute] : undefined,
"attributes", });
] as const); searchResult.hits = searchResult.hits.map((item) => {
if (categories.length > 0) { if (Object.keys(item._matchesPosition).some((match) => match.startsWith("translations"))) {
return categories.map((category) => category.attributes.name); item._formatted.translations = filterDefined(item._formatted.translations).filter(
} (translation) => containsHighlight(JSON.stringify(translation))
return [langui.no_category ?? "No category"]; );
} }
case 1: { return item;
return [ });
item.attributes.type?.data?.attributes?.titles?.[0]?.title ?? setContents(searchResult);
item.attributes.type?.data?.attributes?.slug };
? prettySlug(item.attributes.type.data.attributes.slug) fetchPosts();
: langui.no_type ?? "No type", }, [query, page, sortingMethod, sortingMethods]);
];
}
default: {
return [""];
}
}
},
[groupingMethod, langui]
);
const filteringFunction = useCallback( useEffect(() => {
(item: SelectiveNonNullable<Props["contents"][number], "attributes" | "id">) => { if (router.isReady)
if (searchName.length > 1) { router.updateQuery({
if ( page,
filterDefined(item.attributes.translations).find((translation) => query,
prettyInlineTitle(translation.pre_title, translation.title, translation.subtitle) sort: sortingMethod,
.toLowerCase() });
.includes(searchName.toLowerCase()) // eslint-disable-next-line react-hooks/exhaustive-deps
) }, [page, query, sortingMethod, router.isReady]);
) {
return true; useEffect(() => {
} if (router.isReady) {
return false; if (isDefined(router.query.page)) setPage(router.query.page);
} if (isDefined(router.query.query)) setQuery(router.query.query);
return true; if (isDefined(router.query.sort)) setSortingMethod(router.query.sort);
}, }
[searchName] // eslint-disable-next-line react-hooks/exhaustive-deps
); }, [router.isReady]);
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
<PanelHeader <PanelHeader
icon={Icon.Workspaces} icon="workspaces"
title={langui.contents} title={langui.contents}
description={langui.contents_description} description={langui.contents_description}
/> />
<HorizontalLine /> <HorizontalLine />
<Button href="/contents" text={langui.switch_to_folder_view} icon={Icon.Folder} /> <Button href="/contents" text={langui.switch_to_folder_view} icon="folder" />
<HorizontalLine /> <HorizontalLine />
<TextInput <TextInput
className="mb-6 w-full" className="mb-6 w-full"
placeholder={langui.search_title ?? "Search..."} placeholder={langui.search_title ?? "Search..."}
value={searchName} value={query}
onChange={(name) => { onChange={(name) => {
setSearchName(name); setPage(1);
setQuery(name);
if (isDefinedAndNotEmpty(name)) { if (isDefinedAndNotEmpty(name)) {
sendAnalytics("Contents/All", "Change search term"); sendAnalytics("Contents/All", "Change search term");
} else { } else {
@ -148,19 +156,19 @@ const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
}} }}
/> />
<WithLabel label={langui.group_by}> <WithLabel label={langui.order_by}>
<Select <Select
className="w-full" className="w-full"
options={[langui.category ?? "Category", langui.type ?? "Type"]} options={sortingMethods.map((item) => item.displayedName ?? "")}
value={groupingMethod} value={sortingMethod}
onChange={(value) => { onChange={(newSort) => {
setGroupingMethod(value); setPage(1);
setSortingMethod(newSort);
sendAnalytics( sendAnalytics(
"Contents/All", "Contents/All",
`Change grouping method (${["none", "category", "type"][value + 1]})` `Change sorting method (${sortingMethods.map((item) => item.displayedName)[newSort]})`
); );
}} }}
allowEmpty
/> />
</WithLabel> </WithLabel>
@ -179,10 +187,11 @@ const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
<Button <Button
className="mt-8" className="mt-8"
text={langui.reset_all_filters} text={langui.reset_all_filters}
icon={Icon.Replay} icon="settings_backup_restore"
onClick={() => { onClick={() => {
setSearchName(DEFAULT_FILTERS_STATE.searchName); setPage(1);
setGroupingMethod(DEFAULT_FILTERS_STATE.groupingMethod); setQuery(DEFAULT_FILTERS_STATE.query);
setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod);
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible); setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
sendAnalytics("Contents/All", "Reset all filters"); sendAnalytics("Contents/All", "Reset all filters");
}} }}
@ -192,71 +201,49 @@ const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
const contentPanel = ( const contentPanel = (
<ContentPanel width={ContentPanelWidthSizes.Full}> <ContentPanel width={ContentPanelWidthSizes.Full}>
<SmartList <Paginator page={page} onPageChange={setPage} totalNumberOfPages={contents?.totalPages}>
items={filterHasAttributes(contents, ["attributes", "id"] as const)} <div
getItemId={(item) => item.id} className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
renderItem={({ item }) => ( gap-x-6 gap-y-8">
<TranslatedPreviewCard {contents?.hits.map((item) => (
href={`/contents/${item.attributes.slug}`} <TranslatedPreviewCard
translations={filterHasAttributes(item.attributes.translations, [ key={item.id}
"language.data.attributes.code", href={`/contents/${item.slug}`}
] as const).map((translation) => ({ translations={filterHasAttributes(item._formatted.translations, [
pre_title: translation.pre_title, "language.data.attributes.code",
title: translation.title, ] as const).map(({ displayable_description, language, ...otherAttributes }) => ({
subtitle: translation.subtitle, ...otherAttributes,
language: translation.language.data.attributes.code, description: containsHighlight(displayable_description)
}))} ? displayable_description
fallback={{ title: prettySlug(item.attributes.slug) }} : undefined,
thumbnail={item.attributes.thumbnail?.data?.attributes} language: language.data.attributes.code,
thumbnailAspectRatio="3/2" }))}
thumbnailForceAspectRatio fallback={{ title: prettySlug(item.slug) }}
topChips={ thumbnail={item.thumbnail?.data?.attributes}
item.attributes.type?.data?.attributes thumbnailAspectRatio="3/2"
? [ thumbnailForceAspectRatio
item.attributes.type.data.attributes.titles?.[0] topChips={
? item.attributes.type.data.attributes.titles[0]?.title item.type?.data?.attributes
: prettySlug(item.attributes.type.data.attributes.slug), ? [
] item.type.data.attributes.titles?.[0]
: undefined ? item.type.data.attributes.titles[0]?.title
} : prettySlug(item.type.data.attributes.slug),
bottomChips={item.attributes.categories?.data.map( ]
(category) => category.attributes?.short ?? "" : undefined
)} }
keepInfoVisible={keepInfoVisible} bottomChips={item.categories?.data.map(
/> (category) => category.attributes?.short ?? ""
)} )}
className={cJoin( keepInfoVisible={keepInfoVisible}
"items-end", />
cIf( ))}
isContentPanelAtLeast4xl, </div>
"grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] gap-x-6 gap-y-8", </Paginator>
"grid-cols-2 gap-x-3 gap-y-5"
)
)}
groupingFunction={groupingFunction}
filteringFunction={filteringFunction}
searchingTerm={searchName}
searchingBy={(item) =>
`
${item.attributes.slug}
${filterDefined(item.attributes.translations)
.map((translation) =>
prettyInlineTitle(translation.pre_title, translation.title, translation.subtitle)
)
.join(" ")}`
}
paginationItemPerPage={50}
/>
</ContentPanel> </ContentPanel>
); );
return ( return (
<AppLayout <AppLayout subPanel={subPanel} contentPanel={contentPanel} subPanelIcon="search" {...props} />
subPanel={subPanel}
contentPanel={contentPanel}
subPanelIcon={Icon.Search}
{...otherProps}
/>
); );
}; };
export default Contents; export default Contents;
@ -266,22 +253,10 @@ export default Contents;
* NEXT DATA FETCHING * NEXT DATA FETCHING
*/ */
export const getStaticProps: GetStaticProps = async (context) => { export const getStaticProps: GetStaticProps = (context) => {
const sdk = getReadySdk();
const langui = getLangui(context.locale); const langui = getLangui(context.locale);
const contents = await sdk.getContents({
language_code: context.locale ?? "en",
});
if (!contents.contents) return { notFound: true };
contents.contents.data.sort((a, b) => {
const titleA = a.attributes?.slug ?? "";
const titleB = b.attributes?.slug ?? "";
return naturalCompare(titleA, titleB);
});
const props: Props = { const props: Props = {
contents: contents.contents.data,
openGraph: getOpenGraph(langui, langui.contents ?? "Contents"), openGraph: getOpenGraph(langui, langui.contents ?? "Contents"),
}; };
return { return {

View File

@ -9,7 +9,7 @@ import { GetContentsFolderQuery } from "graphql/generated";
import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales"; import { getDefaultPreferredLanguages, staticSmartLanguage } from "helpers/locales";
import { prettySlug } from "helpers/formatters"; import { prettySlug } from "helpers/formatters";
import { SmartList } from "components/SmartList"; import { SmartList } from "components/SmartList";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { Button, TranslatedButton } from "components/Inputs/Button"; import { Button, TranslatedButton } from "components/Inputs/Button";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
@ -39,14 +39,14 @@ const ContentsFolder = ({ openGraph, folder, ...otherProps }: Props): JSX.Elemen
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
<PanelHeader <PanelHeader
icon={Icon.Workspaces} icon="workspaces"
title={langui.contents} title={langui.contents}
description={langui.contents_description} description={langui.contents_description}
/> />
<HorizontalLine /> <HorizontalLine />
<Button href="/contents/all" text={langui.switch_to_grid_view} icon={Icon.Apps} /> <Button href="/contents/all" text={langui.switch_to_grid_view} icon="apps" />
</SubPanel> </SubPanel>
); );
@ -56,7 +56,7 @@ const ContentsFolder = ({ openGraph, folder, ...otherProps }: Props): JSX.Elemen
{folder.parent_folder?.data?.attributes && ( {folder.parent_folder?.data?.attributes && (
<> <>
{folder.parent_folder.data.attributes.slug === "root" ? ( {folder.parent_folder.data.attributes.slug === "root" ? (
<Button href="/contents" icon={Icon.Home} /> <Button href="/contents" icon="home" />
) : ( ) : (
<TranslatedButton <TranslatedButton
href={`/contents/folder/${folder.parent_folder.data.attributes.slug}`} href={`/contents/folder/${folder.parent_folder.data.attributes.slug}`}
@ -71,12 +71,12 @@ const ContentsFolder = ({ openGraph, folder, ...otherProps }: Props): JSX.Elemen
}} }}
/> />
)} )}
<Ico icon={Icon.ChevronRight} /> <Ico icon="chevron_right" />
</> </>
)} )}
{folder.slug === "root" ? ( {folder.slug === "root" ? (
<Button href="/contents" icon={Icon.Home} active /> <Button href="/contents" icon="home" active />
) : ( ) : (
<TranslatedButton <TranslatedButton
translations={filterHasAttributes(folder.titles, [ translations={filterHasAttributes(folder.titles, [

View File

@ -7,7 +7,6 @@ import { Markdawn, TableOfContents } from "components/Markdown/Markdawn";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { Popup } from "components/Containers/Popup"; import { Popup } from "components/Containers/Popup";
import { ToolTip } from "components/ToolTip"; import { ToolTip } from "components/ToolTip";
import { Icon } from "components/Ico";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
@ -208,15 +207,15 @@ const Editor = (props: Props): JSX.Element => {
<Button onClick={() => preline("###### ")} text={"H6"} /> <Button onClick={() => preline("###### ")} text={"H6"} />
</div> </div>
}> }>
<Button icon={Icon.Title} /> <Button icon="title" />
</ToolTip> </ToolTip>
<ToolTip placement="bottom" content={<h3 className="text-lg">Toggle Bold</h3>}> <ToolTip placement="bottom" content={<h3 className="text-lg">Toggle Bold</h3>}>
<Button onClick={() => toggleWrap("**")} icon={Icon.FormatBold} /> <Button onClick={() => toggleWrap("**")} icon="format_bold" />
</ToolTip> </ToolTip>
<ToolTip placement="bottom" content={<h3 className="text-lg">Toggle Italic</h3>}> <ToolTip placement="bottom" content={<h3 className="text-lg">Toggle Italic</h3>}>
<Button onClick={() => toggleWrap("_")} icon={Icon.FormatItalic} /> <Button onClick={() => toggleWrap("_")} icon="format_italic" />
</ToolTip> </ToolTip>
<ToolTip <ToolTip
@ -230,7 +229,7 @@ const Editor = (props: Props): JSX.Element => {
</p> </p>
</> </>
}> }>
<Button onClick={() => toggleWrap("`")} icon={Icon.Code} /> <Button onClick={() => toggleWrap("`")} icon="code" />
</ToolTip> </ToolTip>
<ToolTip <ToolTip
@ -246,7 +245,7 @@ const Editor = (props: Props): JSX.Element => {
insert("[^x]"); insert("[^x]");
appendDoc("\n\n[^x]: This is a footnote."); appendDoc("\n\n[^x]: This is a footnote.");
}} }}
icon={Icon.Superscript} icon="superscript"
/> />
</ToolTip> </ToolTip>
@ -267,7 +266,7 @@ const Editor = (props: Props): JSX.Element => {
<h3 className="text-lg">Transcript container</h3> <h3 className="text-lg">Transcript container</h3>
</> </>
}> }>
<Button onClick={() => wrap("Transcript", {}, true)} icon={Icon.AddBox} /> <Button onClick={() => wrap("Transcript", {}, true)} icon="add_box" />
</ToolTip> </ToolTip>
<ToolTip <ToolTip
placement="right" placement="right"
@ -282,20 +281,20 @@ const Editor = (props: Props): JSX.Element => {
}> }>
<Button <Button
onClick={() => wrap("Line", { name: "speaker" })} onClick={() => wrap("Line", { name: "speaker" })}
icon={Icon.RecordVoiceOver} icon="record_voice_over"
/> />
</ToolTip> </ToolTip>
</div> </div>
</> </>
}> }>
<Button icon={Icon.RecordVoiceOver} /> <Button icon="record_voice_over" />
</ToolTip> </ToolTip>
<ToolTip placement="bottom" content={<h3 className="text-lg">Inset box</h3>}> <ToolTip placement="bottom" content={<h3 className="text-lg">Inset box</h3>}>
<Button onClick={() => wrap("InsetBox", {}, true)} icon={Icon.CheckBoxOutlineBlank} /> <Button onClick={() => wrap("InsetBox", {}, true)} icon="check_box_outline_blank" />
</ToolTip> </ToolTip>
<ToolTip placement="bottom" content={<h3 className="text-lg">Scene break</h3>}> <ToolTip placement="bottom" content={<h3 className="text-lg">Scene break</h3>}>
<Button onClick={() => insert("\n* * *\n")} icon={Icon.MoreHoriz} /> <Button onClick={() => insert("\n* * *\n")} icon="more_horiz" />
</ToolTip> </ToolTip>
<ToolTip <ToolTip
content={ content={
@ -311,7 +310,7 @@ const Editor = (props: Props): JSX.Element => {
}> }>
<Button <Button
onClick={() => insert("[Link name](https://domain.com)")} onClick={() => insert("[Link name](https://domain.com)")}
icon={Icon.Link} icon="link"
text={"External"} text={"External"}
/> />
</ToolTip> </ToolTip>
@ -326,7 +325,7 @@ const Editor = (props: Props): JSX.Element => {
</p> </p>
</> </>
}> }>
<Button onClick={() => wrap("IntraLink", {})} icon={Icon.Link} text={"Internal"} /> <Button onClick={() => wrap("IntraLink", {})} icon="link" text={"Internal"} />
</ToolTip> </ToolTip>
<ToolTip <ToolTip
placement="right" placement="right"
@ -341,19 +340,19 @@ const Editor = (props: Props): JSX.Element => {
}> }>
<Button <Button
onClick={() => wrap("IntraLink", { target: "target" })} onClick={() => wrap("IntraLink", { target: "target" })}
icon={Icon.Link} icon="link"
text="Internal (w/ target)" text="Internal (w/ target)"
/> />
</ToolTip> </ToolTip>
</div> </div>
}> }>
<Button icon={Icon.Link} /> <Button icon="link" />
</ToolTip> </ToolTip>
<ToolTip <ToolTip
placement="bottom" placement="bottom"
content={<h3 className="text-lg">Player&rsquo;s name placeholder</h3>}> content={<h3 className="text-lg">Player&rsquo;s name placeholder</h3>}>
<Button onClick={() => insert("@player")} icon={Icon.Person} /> <Button onClick={() => insert("@player")} icon="person" />
</ToolTip> </ToolTip>
<ToolTip placement="bottom" content={<h3 className="text-lg">Open HTML Converter</h3>}> <ToolTip placement="bottom" content={<h3 className="text-lg">Open HTML Converter</h3>}>
@ -361,7 +360,7 @@ const Editor = (props: Props): JSX.Element => {
onClick={() => { onClick={() => {
setConverterOpened(true); setConverterOpened(true);
}} }}
icon={Icon.Html} icon="html"
/> />
</ToolTip> </ToolTip>
</div> </div>

View File

@ -7,7 +7,6 @@ import { getLangui } from "graphql/fetchLocalData";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { Icon } from "components/Ico";
import { cJoin } from "helpers/className"; import { cJoin } from "helpers/className";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
@ -172,24 +171,24 @@ const DesignSystem = (props: Props): JSX.Element => {
<p>Icon + Text</p> <p>Icon + Text</p>
<p className="self-center justify-self-start">Normal</p> <p className="self-center justify-self-start">Normal</p>
<Button icon={Icon.Check} /> <Button icon="check" />
<Button text="Label" /> <Button text="Label" />
<Button icon={Icon.NavigateBefore} text="Label" /> <Button icon="navigate_before" text="Label" />
<p className="self-center justify-self-start">Active</p> <p className="self-center justify-self-start">Active</p>
<Button icon={Icon.Camera} active /> <Button icon="camera" active />
<Button text="Label" active /> <Button text="Label" active />
<Button icon={Icon.NavigateBefore} text="Label" active /> <Button icon="navigate_before" text="Label" active />
<p className="self-center justify-self-start">Disabled</p> <p className="self-center justify-self-start">Disabled</p>
<Button icon={Icon.Air} disabled /> <Button icon="air" disabled />
<Button text="Label" disabled /> <Button text="Label" disabled />
<Button icon={Icon.NavigateBefore} text="Label" disabled /> <Button icon="navigate_before" text="Label" disabled />
<p className="self-center justify-self-start">Badge</p> <p className="self-center justify-self-start">Badge</p>
<Button icon={Icon.Snooze} badgeNumber={5} /> <Button icon="snooze" badgeNumber={5} />
<Button text="Label" badgeNumber={12} /> <Button text="Label" badgeNumber={12} />
<Button icon={Icon.NavigateBefore} text="Label" badgeNumber={201} /> <Button icon="navigate_before" text="Label" badgeNumber={201} />
</div> </div>
<HorizontalLine /> <HorizontalLine />
@ -197,43 +196,39 @@ const DesignSystem = (props: Props): JSX.Element => {
<div className="grid grid-cols-[repeat(4,auto)] place-content-center gap-4"> <div className="grid grid-cols-[repeat(4,auto)] place-content-center gap-4">
<p className="self-center justify-self-start">Normal</p> <p className="self-center justify-self-start">Normal</p>
<Button icon={Icon.Check} size={"small"} /> <Button icon="check" size={"small"} />
<Button text="Label" size={"small"} /> <Button text="Label" size={"small"} />
<Button icon={Icon.NavigateBefore} text="Label" size={"small"} /> <Button icon="navigate_before" text="Label" size={"small"} />
<p className="self-center justify-self-start">Active</p> <p className="self-center justify-self-start">Active</p>
<Button icon={Icon.Camera} active size={"small"} /> <Button icon="camera" active size={"small"} />
<Button text="Label" active size={"small"} /> <Button text="Label" active size={"small"} />
<Button icon={Icon.NavigateBefore} text="Label" active size={"small"} /> <Button icon="navigate_before" text="Label" active size={"small"} />
<p className="self-center justify-self-start">Disabled</p> <p className="self-center justify-self-start">Disabled</p>
<Button icon={Icon.Air} disabled size={"small"} /> <Button icon="air" disabled size={"small"} />
<Button text="Label" disabled size={"small"} /> <Button text="Label" disabled size={"small"} />
<Button icon={Icon.NavigateBefore} text="Label" disabled size={"small"} /> <Button icon="navigate_before" text="Label" disabled size={"small"} />
<p className="self-center justify-self-start">Badge</p> <p className="self-center justify-self-start">Badge</p>
<Button icon={Icon.Snooze} badgeNumber={5} size={"small"} /> <Button icon="snooze" badgeNumber={5} size={"small"} />
<Button text="Label" badgeNumber={12} size={"small"} /> <Button text="Label" badgeNumber={12} size={"small"} />
<Button icon={Icon.NavigateBefore} text="Label" badgeNumber={201} size={"small"} /> <Button icon="navigate_before" text="Label" badgeNumber={201} size={"small"} />
</div> </div>
<HorizontalLine /> <HorizontalLine />
<h3 className="text-xl">Groups</h3> <h3 className="text-xl">Groups</h3>
<div className="grid place-items-center gap-4"> <div className="grid place-items-center gap-4">
<ButtonGroup buttonsProps={[{ icon: Icon.CallEnd }, { icon: Icon.ZoomInMap }]} /> <ButtonGroup buttonsProps={[{ icon: "call_end" }, { icon: "zoom_in_map" }]} />
<ButtonGroup <ButtonGroup
buttonsProps={[ buttonsProps={[{ icon: "car_crash" }, { icon: "timelapse" }, { icon: "leak_add" }]}
{ icon: Icon.CarCrash },
{ icon: Icon.TimeToLeave },
{ icon: Icon.LeakAdd },
]}
/> />
<ButtonGroup <ButtonGroup
buttonsProps={[ buttonsProps={[
{ icon: Icon.CarCrash }, { icon: "car_crash" },
{ icon: Icon.TimeToLeave, text: "Label", active: true }, { icon: "timelapse", text: "Label", active: true },
{ text: "Another Label" }, { text: "Another Label" },
{ icon: Icon.Cable }, { icon: "cable" },
]} ]}
/> />
<ButtonGroup <ButtonGroup
@ -244,7 +239,7 @@ const DesignSystem = (props: Props): JSX.Element => {
onClick: () => setButtonGroupState(0), onClick: () => setButtonGroupState(0),
}, },
{ {
icon: Icon.AdUnits, icon: "ad_units",
text: "Label", text: "Label",
active: buttonGroupState === 1, active: buttonGroupState === 1,
onClick: () => setButtonGroupState(1), onClick: () => setButtonGroupState(1),
@ -255,7 +250,7 @@ const DesignSystem = (props: Props): JSX.Element => {
onClick: () => setButtonGroupState(2), onClick: () => setButtonGroupState(2),
}, },
{ {
icon: Icon.Security, icon: "security",
active: buttonGroupState === 3, active: buttonGroupState === 3,
onClick: () => setButtonGroupState(3), onClick: () => setButtonGroupState(3),
}, },
@ -462,52 +457,41 @@ const DesignSystem = (props: Props): JSX.Element => {
<p>Normal</p> <p>Normal</p>
<NavOption title="Title" url="#" /> <NavOption title="Title" url="#" />
<NavOption icon={Icon.Home} title="Title" url="#" /> <NavOption icon="home" title="Title" url="#" />
<NavOption title="Title" subtitle="This is a subtitle" url="#" /> <NavOption title="Title" subtitle="This is a subtitle" url="#" />
<NavOption title="Title" subtitle="This is a subtitle" url="#" icon="calendar_month" />
<NavOption <NavOption
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.CalendarMonth} icon="account_balance"
/>
<NavOption
title="Title"
subtitle="This is a subtitle"
url="#"
icon={Icon.AccountBalance}
reduced reduced
/> />
<p>Border</p> <p>Border</p>
<NavOption title="Title" url="#" border /> <NavOption title="Title" url="#" border />
<NavOption icon={Icon.TravelExplore} title="Title" url="#" border /> <NavOption icon="travel_explore" title="Title" url="#" border />
<NavOption title="Title" subtitle="This is a subtitle" url="#" border /> <NavOption title="Title" subtitle="This is a subtitle" url="#" border />
<NavOption title="Title" subtitle="This is a subtitle" url="#" icon={Icon.Help} border /> <NavOption title="Title" subtitle="This is a subtitle" url="#" icon="help" border />
<NavOption <NavOption
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.TableRestaurant} icon="table_restaurant"
border border
reduced reduced
/> />
<p>Active</p> <p>Active</p>
<NavOption title="Title" url="#" active /> <NavOption title="Title" url="#" active />
<NavOption icon={Icon.Hail} title="Title" url="#" active /> <NavOption icon="hail" title="Title" url="#" active />
<NavOption title="Title" subtitle="This is a subtitle" url="#" active /> <NavOption title="Title" subtitle="This is a subtitle" url="#" active />
<NavOption title="Title" subtitle="This is a subtitle" url="#" icon="grading" active />
<NavOption <NavOption
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.Grading} icon="timer"
active
/>
<NavOption
title="Title"
subtitle="This is a subtitle"
url="#"
icon={Icon.Timer}
active active
reduced reduced
/> />
@ -517,13 +501,13 @@ const DesignSystem = (props: Props): JSX.Element => {
<br />+ Border <br />+ Border
</p> </p>
<NavOption title="Title" url="#" active /> <NavOption title="Title" url="#" active />
<NavOption icon={Icon.Upcoming} title="Title" url="#" active border /> <NavOption icon="upcoming" title="Title" url="#" active border />
<NavOption title="Title" subtitle="This is a subtitle" url="#" active border /> <NavOption title="Title" subtitle="This is a subtitle" url="#" active border />
<NavOption <NavOption
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.Gamepad} icon="gamepad"
active active
border border
/> />
@ -531,7 +515,7 @@ const DesignSystem = (props: Props): JSX.Element => {
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.Scale} icon="scale"
active active
border border
reduced reduced
@ -539,20 +523,20 @@ const DesignSystem = (props: Props): JSX.Element => {
<p>Disabled</p> <p>Disabled</p>
<NavOption title="Title" url="#" disabled /> <NavOption title="Title" url="#" disabled />
<NavOption icon={Icon.Lan} title="Title" url="#" disabled /> <NavOption icon="lan" title="Title" url="#" disabled />
<NavOption title="Title" subtitle="This is a subtitle" url="#" disabled /> <NavOption title="Title" subtitle="This is a subtitle" url="#" disabled />
<NavOption <NavOption
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.AlignHorizontalRight} icon="align_horizontal_right"
disabled disabled
/> />
<NavOption <NavOption
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.YoutubeSearchedFor} icon="youtube_searched_for"
reduced reduced
disabled disabled
/> />
@ -562,13 +546,13 @@ const DesignSystem = (props: Props): JSX.Element => {
<br />+ Border <br />+ Border
</p> </p>
<NavOption title="Title" url="#" border disabled /> <NavOption title="Title" url="#" border disabled />
<NavOption icon={Icon.Sanitizer} title="Title" url="#" border disabled /> <NavOption icon="sanitizer" title="Title" url="#" border disabled />
<NavOption title="Title" subtitle="This is a subtitle" url="#" border disabled /> <NavOption title="Title" subtitle="This is a subtitle" url="#" border disabled />
<NavOption <NavOption
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.Pages} icon="pages"
border border
disabled disabled
/> />
@ -576,7 +560,7 @@ const DesignSystem = (props: Props): JSX.Element => {
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.Synagogue} icon="synagogue"
border border
reduced reduced
disabled disabled
@ -587,13 +571,13 @@ const DesignSystem = (props: Props): JSX.Element => {
<br />+ Active <br />+ Active
</p> </p>
<NavOption title="Title" url="#" active disabled /> <NavOption title="Title" url="#" active disabled />
<NavOption icon={Icon.Stairs} title="Title" url="#" active disabled /> <NavOption icon="stairs" title="Title" url="#" active disabled />
<NavOption title="Title" subtitle="This is a subtitle" url="#" active disabled /> <NavOption title="Title" subtitle="This is a subtitle" url="#" active disabled />
<NavOption <NavOption
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.Park} icon="park"
active active
disabled disabled
/> />
@ -601,7 +585,7 @@ const DesignSystem = (props: Props): JSX.Element => {
title="Title" title="Title"
subtitle="This is a subtitle" subtitle="This is a subtitle"
url="#" url="#"
icon={Icon.Password} icon="password"
active active
reduced reduced
disabled disabled

View File

@ -42,7 +42,7 @@ import { useScrollTopOnChange } from "hooks/useScrollTopOnChange";
import { isUntangibleGroupItem } from "helpers/libraryItem"; import { isUntangibleGroupItem } from "helpers/libraryItem";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { cJoin, cIf } from "helpers/className"; import { cJoin, cIf } from "helpers/className";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
@ -740,7 +740,7 @@ const ContentLine = ({
className={`grid-flow-col place-content-start place-items-center gap-2 ${ className={`grid-flow-col place-content-start place-items-center gap-2 ${
isOpened ? "grid" : "hidden" isOpened ? "grid" : "hidden"
}`}> }`}>
<Ico icon={Icon.SubdirectoryArrowRight} className="text-dark" /> <Ico icon={"subdirectory_arrow_right"} className="text-dark" />
{hasScanSet || isDefined(content) ? ( {hasScanSet || isDefined(content) ? (
<> <>

View File

@ -2,8 +2,8 @@ import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
import { Fragment, useCallback, useEffect, useState } from "react"; import { Fragment, useCallback, useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook"; import { useHotkeys } from "react-hotkeys-hook";
import Slider from "rc-slider"; import Slider from "rc-slider";
import { useRouter } from "next/router";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import { z } from "zod";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { import {
Enum_Componentmetadatabooks_Page_Order as PageOrder, Enum_Componentmetadatabooks_Page_Order as PageOrder,
@ -22,7 +22,6 @@ import { cIf, cJoin } from "helpers/className";
import { clamp, isInteger } from "helpers/numbers"; import { clamp, isInteger } from "helpers/numbers";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { Icon } from "components/Ico";
import { Ids } from "types/ids"; import { Ids } from "types/ids";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
@ -40,6 +39,15 @@ import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { FilterSettings, useReaderSettings } from "hooks/useReaderSettings"; import { FilterSettings, useReaderSettings } from "hooks/useReaderSettings";
import { useIsWebkit } from "hooks/useIsWebkit"; import { useIsWebkit } from "hooks/useIsWebkit";
import { useTypedRouter } from "hooks/useTypedRouter";
type BookType = "book" | "manga";
type DisplayMode = "double" | "single";
/*
*
* CONSTANTS
*/
const CUSTOM_DARK_DROPSHADOW = ` const CUSTOM_DARK_DROPSHADOW = `
drop-shadow(0 0 0.5em rgb(var(--theme-color-shade) / 30%)) drop-shadow(0 0 0.5em rgb(var(--theme-color-shade) / 30%))
@ -56,8 +64,10 @@ const CUSTOM_LIGHT_DROPSHADOW = `
const SIDEPAGES_PAGE_COUNT_ON_TEXTURE = 200; const SIDEPAGES_PAGE_COUNT_ON_TEXTURE = 200;
const SIDEPAGES_PAGE_WIDTH = 0.02; const SIDEPAGES_PAGE_WIDTH = 0.02;
type BookType = "book" | "manga"; const queryParamSchema = z.object({
type DisplayMode = "double" | "single"; query: z.coerce.string().optional(),
page: z.coerce.number().positive().optional(),
});
/* /*
* *
@ -107,7 +117,7 @@ const LibrarySlug = ({
const [displayMode, setDisplayMode] = useState<DisplayMode>( const [displayMode, setDisplayMode] = useState<DisplayMode>(
is1ColumnLayout ? "single" : "double" is1ColumnLayout ? "single" : "double"
); );
const router = useRouter(); const router = useTypedRouter(queryParamSchema);
const isWebkit = useIsWebkit(); const isWebkit = useIsWebkit();
const { isFullscreen, toggleFullscreen, requestFullscreen } = useFullscreen(Ids.ContentPanel); const { isFullscreen, toggleFullscreen, requestFullscreen } = useFullscreen(Ids.ContentPanel);
@ -119,12 +129,7 @@ const LibrarySlug = ({
const changeCurrentPageIndex = useCallback( const changeCurrentPageIndex = useCallback(
(callbackFn: (current: number) => number) => { (callbackFn: (current: number) => number) => {
setCurrentPageIndex((current) => { setCurrentPageIndex((current) => clamp(callbackFn(current), 0, pages.length - 1));
let result = callbackFn(current);
result = clamp(result, 0, pages.length - 1);
window.history.replaceState({}, "", `?page=${result - 1}`);
return result;
});
}, },
[pages.length] [pages.length]
); );
@ -132,13 +137,19 @@ const LibrarySlug = ({
useEffect(() => setDisplayMode(is1ColumnLayout ? "single" : "double"), [is1ColumnLayout]); useEffect(() => setDisplayMode(is1ColumnLayout ? "single" : "double"), [is1ColumnLayout]);
useEffect(() => { useEffect(() => {
const indexQueryString = router.asPath.indexOf("?page="); if (router.isReady)
if (indexQueryString > 0) { router.updateQuery({
const page = parseInt(router.asPath.slice(indexQueryString + "?page=".length), 10); page: currentPageIndex - 1,
changeCurrentPageIndex(() => page + 1); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPageIndex, router.isReady]);
useEffect(() => {
if (router.isReady) {
if (isDefined(router.query.page)) setCurrentPageIndex(router.query.page + 1);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.asPath]); }, [router.isReady]);
const changeDisplayMode = useCallback( const changeDisplayMode = useCallback(
(newDisplayMode: DisplayMode) => { (newDisplayMode: DisplayMode) => {
@ -311,13 +322,13 @@ const LibrarySlug = ({
<ButtonGroup <ButtonGroup
buttonsProps={[ buttonsProps={[
{ {
icon: Icon.Description, icon: "description",
tooltip: langui.single_page_view, tooltip: langui.single_page_view,
active: displayMode === "single", active: displayMode === "single",
onClick: () => changeDisplayMode("single"), onClick: () => changeDisplayMode("single"),
}, },
{ {
icon: Icon.AutoStories, icon: "auto_stories",
tooltip: langui.double_page_view, tooltip: langui.double_page_view,
active: displayMode === "double", active: displayMode === "double",
onClick: () => changeDisplayMode("double"), onClick: () => changeDisplayMode("double"),
@ -347,7 +358,7 @@ const LibrarySlug = ({
<Button <Button
className="mt-8" className="mt-8"
text={langui.reset_all_options} text={langui.reset_all_options}
icon={Icon.Replay} icon="settings_backup_restore"
onClick={() => { onClick={() => {
resetReaderSettings(); resetReaderSettings();
setDisplayMode(is1ColumnLayout ? "single" : "double"); setDisplayMode(is1ColumnLayout ? "single" : "double");
@ -364,7 +375,6 @@ const LibrarySlug = ({
onZoom={(zoom) => setCurrentZoom(zoom.state.scale)} onZoom={(zoom) => setCurrentZoom(zoom.state.scale)}
panning={{ disabled: currentZoom <= 1, velocityDisabled: false }} panning={{ disabled: currentZoom <= 1, velocityDisabled: false }}
doubleClick={{ disabled: true, mode: "reset" }} doubleClick={{ disabled: true, mode: "reset" }}
zoomAnimation={{ size: 0.1 }}
velocityAnimation={{ animationTime: 0, equalToMove: true }}> velocityAnimation={{ animationTime: 0, equalToMove: true }}>
<TransformComponent <TransformComponent
wrapperStyle={{ overflow: "visible", placeSelf: "center" }} wrapperStyle={{ overflow: "visible", placeSelf: "center" }}
@ -497,12 +507,12 @@ const LibrarySlug = ({
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
icon={isGalleryMode ? Icon.ExpandMore : Icon.ExpandLess} icon={isGalleryMode ? "expand_more" : "expand_less"}
onClick={() => setIsGalleryMode((current) => !current)} onClick={() => setIsGalleryMode((current) => !current)}
size="small" size="small"
/> />
<Button <Button
icon={isFullscreen ? Icon.FullscreenExit : Icon.Fullscreen} icon={isFullscreen ? "fullscreen_exit" : "fullscreen"}
onClick={toggleFullscreen} onClick={toggleFullscreen}
size="small" size="small"
/> />
@ -688,9 +698,8 @@ interface PageFiltersProps {
} }
const PageFilters = ({ page, bookType, options }: PageFiltersProps) => { const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
const isDarkMode = useAtomGetter(atoms.settings.darkMode);
const commonCss = cJoin( const commonCss = cJoin(
"absolute inset-0", "absolute inset-0 dark:opacity-100",
cIf(page === "right", "[background-position-x:-100%]") cIf(page === "right", "[background-position-x:-100%]")
); );
@ -700,9 +709,9 @@ const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
<div <div
className={cJoin( className={cJoin(
commonCss, commonCss,
`bg-blend-multiply mix-blend-exclusion [background-image:url(/reader/paper.webp)] `mix-blend-exclusion [background-image:url(/reader/paper.webp)]
[background-size:20vmin_20vmin]`, [background-size:20vmin_20vmin]`,
cIf(bookType === "book", "bg-[#000]/60") cIf(bookType === "book", "opacity-60 dark:opacity-60")
)} )}
/> />
)} )}
@ -711,9 +720,8 @@ const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
<div <div
className={cJoin( className={cJoin(
commonCss, commonCss,
`bg-blend-lighten mix-blend-multiply [background-image:url(/reader/book-fold.webp)] `opacity-50 mix-blend-multiply
[background-size:200%_100%]`, [background-image:url(/reader/book-fold.webp)] [background-size:200%_100%]`
cIf(!isDarkMode, "bg-[#FFF]/50")
)} )}
/> />
)} )}
@ -723,8 +731,7 @@ const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
<div <div
className={cJoin( className={cJoin(
commonCss, commonCss,
`bg-blend-lighten mix-blend-multiply [background-size:200%_100%]`, "opacity-50 mix-blend-multiply [background-size:200%_100%]",
cIf(!isDarkMode, "bg-[#FFF]/50"),
cIf( cIf(
page === "single", page === "single",
"[background-image:url(/reader/lighting-single-page.webp)]", "[background-image:url(/reader/lighting-single-page.webp)]",
@ -735,8 +742,8 @@ const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
<div <div
className={cJoin( className={cJoin(
commonCss, commonCss,
`bg-blend-lighten mix-blend-soft-light [background-size:200%_100%]`, `bg-[#FFF]/30 bg-blend-lighten mix-blend-soft-light [background-size:200%_100%]
cIf(!isDarkMode, "bg-[#FFF]/30"), dark:bg-[#000]`,
cIf( cIf(
page === "single", page === "single",
"[background-image:url(/reader/specular-single-page.webp)]", "[background-image:url(/reader/specular-single-page.webp)]",

View File

@ -1,44 +1,41 @@
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import { useState, useCallback } from "react"; import { useEffect, useMemo, useState } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import naturalCompare from "string-natural-compare"; import { z } from "zod";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Select } from "components/Inputs/Select"; import { Select } from "components/Inputs/Select";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { GetLibraryItemsPreviewQuery } from "graphql/generated";
import { getReadySdk } from "graphql/sdk";
import { prettyInlineTitle, prettyItemSubType } from "helpers/formatters";
import { LibraryItemUserStatus } from "types/types"; import { LibraryItemUserStatus } from "types/types";
import { Icon } from "components/Ico";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
import { TextInput } from "components/Inputs/TextInput"; import { TextInput } from "components/Inputs/TextInput";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs";
import { isUntangibleGroupItem } from "helpers/libraryItem";
import { PreviewCard } from "components/PreviewCard";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { ButtonGroup } from "components/Inputs/ButtonGroup"; import { ButtonGroup } from "components/Inputs/ButtonGroup";
import { import {
filterDefined,
filterHasAttributes, filterHasAttributes,
isDefined, isDefined,
isDefinedAndNotEmpty, isDefinedAndNotEmpty,
isUndefined, isUndefined,
SelectiveNonNullable,
} from "helpers/asserts"; } from "helpers/asserts";
import { convertPrice } from "helpers/numbers";
import { SmartList } from "components/SmartList";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { compareDate } from "helpers/date";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { cIf, cJoin } from "helpers/className";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
import { sendAnalytics } from "helpers/analytics"; import { sendAnalytics } from "helpers/analytics";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { containsHighlight, CustomSearchResponse, meiliSearch } from "helpers/search";
import { MeiliIndices, MeiliLibraryItem } from "shared/meilisearch-graphql-typings/meiliTypes";
import { useTypedRouter } from "hooks/useTypedRouter";
import { TranslatedPreviewCard } from "components/PreviewCard";
import { prettyItemSubType } from "helpers/formatters";
import { isUntangibleGroupItem } from "helpers/libraryItem";
import { PreviewCardCTAs } from "components/Library/PreviewCardCTAs";
import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus"; import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus";
import { Paginator } from "components/Containers/Paginator";
/* /*
* *
@ -46,52 +43,69 @@ import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus";
*/ */
const DEFAULT_FILTERS_STATE = { const DEFAULT_FILTERS_STATE = {
searchName: "", query: "",
showSubitems: false, showSubitems: false,
showPrimaryItems: true, showPrimaryItems: true,
showSecondaryItems: false, showSecondaryItems: false,
page: 1,
sortingMethod: 0, sortingMethod: 0,
groupingMethod: -1,
keepInfoVisible: false, keepInfoVisible: false,
filterUserStatus: undefined, filterUserStatus: undefined,
}; };
const queryParamSchema = z.object({
query: z.coerce.string().optional(),
page: z.coerce.number().positive().optional(),
sort: z.coerce.number().min(0).max(5).optional(),
subitems: z.coerce.boolean().optional(),
primary: z.coerce.boolean().optional(),
secondary: z.coerce.boolean().optional(),
status: z.coerce.string().optional(),
});
/* /*
* *
* PAGE * PAGE
*/ */
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {}
items: NonNullable<GetLibraryItemsPreviewQuery["libraryItems"]>["data"];
}
const Library = ({ items, ...otherProps }: Props): JSX.Element => { const Library = (props: Props): JSX.Element => {
const hoverable = useDeviceSupportsHover(); const hoverable = useDeviceSupportsHover();
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
const currencies = useAtomGetter(atoms.localData.currencies);
const { libraryItemUserStatus } = useLibraryItemUserStatus(); const { libraryItemUserStatus } = useLibraryItemUserStatus();
const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl);
const [searchName, setSearchName] = useState(DEFAULT_FILTERS_STATE.searchName); const sortingMethods = useMemo(
() => [
{ meiliAttribute: "sortable_name:asc", displayedName: langui.name },
{ meiliAttribute: "sortable_date:asc", displayedName: langui.release_date },
{ meiliAttribute: "sortable_price:asc", displayedName: langui.price },
],
[langui.name, langui.price, langui.release_date]
);
const router = useTypedRouter(queryParamSchema);
const [page, setPage] = useState<number>(router.query.page ?? DEFAULT_FILTERS_STATE.page);
const [libraryItems, setLibraryItems] = useState<CustomSearchResponse<MeiliLibraryItem>>();
const [query, setQuery] = useState(router.query.query ?? DEFAULT_FILTERS_STATE.query);
const { const {
value: showSubitems, value: showSubitems,
toggle: toggleShowSubitems, toggle: toggleShowSubitems,
setValue: setShowSubitems, setValue: setShowSubitems,
} = useBoolean(DEFAULT_FILTERS_STATE.showSubitems); } = useBoolean(router.query.subitems ?? DEFAULT_FILTERS_STATE.showSubitems);
const { const {
value: showPrimaryItems, value: showPrimaryItems,
toggle: toggleShowPrimaryItems, toggle: toggleShowPrimaryItems,
setValue: setShowPrimaryItems, setValue: setShowPrimaryItems,
} = useBoolean(DEFAULT_FILTERS_STATE.showPrimaryItems); } = useBoolean(router.query.primary ?? DEFAULT_FILTERS_STATE.showPrimaryItems);
const { const {
value: showSecondaryItems, value: showSecondaryItems,
toggle: toggleShowSecondaryItems, toggle: toggleShowSecondaryItems,
setValue: setShowSecondaryItems, setValue: setShowSecondaryItems,
} = useBoolean(DEFAULT_FILTERS_STATE.showSecondaryItems); } = useBoolean(router.query.secondary ?? DEFAULT_FILTERS_STATE.showSecondaryItems);
const { const {
value: keepInfoVisible, value: keepInfoVisible,
@ -99,141 +113,142 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
setValue: setKeepInfoVisible, setValue: setKeepInfoVisible,
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible); } = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
const [sortingMethod, setSortingMethod] = useState<number>(DEFAULT_FILTERS_STATE.sortingMethod); const [sortingMethod, setSortingMethod] = useState<number>(
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
const [groupingMethod, setGroupingMethod] = useState<number>(
DEFAULT_FILTERS_STATE.groupingMethod
); );
const [filterUserStatus, setFilterUserStatus] = useState<LibraryItemUserStatus | undefined>( const [filterUserStatus, setFilterUserStatus] = useState<LibraryItemUserStatus | undefined>(
DEFAULT_FILTERS_STATE.filterUserStatus fromStringToLibraryItemUserStatus(router.query.status) ?? DEFAULT_FILTERS_STATE.filterUserStatus
); );
const filteringFunction = useCallback( useEffect(() => {
(item: SelectiveNonNullable<Props["items"][number], "attributes" | "id">) => { const fetchLibraryItems = async () => {
if (!showSubitems && !item.attributes.root_item) return false; const currentSortingMethod = sortingMethods[sortingMethod];
if (showSubitems && isUntangibleGroupItem(item.attributes.metadata?.[0])) { const filter: string[] = [];
return false;
}
if (item.attributes.primary && !showPrimaryItems) return false;
if (!item.attributes.primary && !showSecondaryItems) return false;
if (isDefined(filterUserStatus) && item.id) { if (!showPrimaryItems && !showSecondaryItems) {
if (isUntangibleGroupItem(item.attributes.metadata?.[0])) { filter.push("primary NOT EXISTS");
return false; } else if (showPrimaryItems && !showSecondaryItems) {
} filter.push("primary = true");
} else if (!showPrimaryItems && showSecondaryItems) {
filter.push("primary = false");
}
if (showSubitems) {
filter.push("untangible_group_item = false");
} else {
filter.push("root_item = true");
}
if (isDefined(filterUserStatus)) {
filter.push("untangible_group_item = false");
if (filterUserStatus === LibraryItemUserStatus.None) { if (filterUserStatus === LibraryItemUserStatus.None) {
if (libraryItemUserStatus[item.id]) { filter.push(
return false; `id NOT IN [${Object.entries(libraryItemUserStatus)
} .filter(([, value]) => value !== filterUserStatus)
} else if (filterUserStatus !== libraryItemUserStatus[item.id]) { .map(([id]) => id)
return false; .join(", ")}]`
);
} else {
filter.push(
`id IN [${Object.entries(libraryItemUserStatus)
.filter(([, value]) => value === filterUserStatus)
.map(([id]) => id)
.join(", ")}]`
);
} }
} }
return true;
},
[libraryItemUserStatus, filterUserStatus, showPrimaryItems, showSecondaryItems, showSubitems]
);
const sortingFunction = useCallback( const searchResult = await meiliSearch(MeiliIndices.LIBRARY_ITEM, query, {
( hitsPerPage: 25,
a: SelectiveNonNullable<Props["items"][number], "attributes" | "id">, page,
b: SelectiveNonNullable<Props["items"][number], "attributes" | "id"> attributesToRetrieve: [
) => { "title",
switch (sortingMethod) { "subtitle",
case 0: { "descriptions",
const titleA = prettyInlineTitle("", a.attributes.title, a.attributes.subtitle); "id",
const titleB = prettyInlineTitle("", b.attributes.title, b.attributes.subtitle); "slug",
return naturalCompare(titleA, titleB); "thumbnail",
"release_date",
"price",
"categories",
"metadata",
],
attributesToHighlight: ["title", "subtitle", "descriptions"],
attributesToCrop: ["descriptions"],
sort: isDefined(currentSortingMethod) ? [currentSortingMethod.meiliAttribute] : undefined,
filter,
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("descriptions"))) {
item._formatted.descriptions = filterDefined(item._formatted.descriptions).filter(
(description) => containsHighlight(JSON.stringify(description))
);
} }
case 1: { return item;
const commonCurrency = currencies[0]; });
if (isUndefined(commonCurrency)) return 0; setLibraryItems(searchResult);
};
fetchLibraryItems();
}, [
filterUserStatus,
libraryItemUserStatus,
page,
query,
showPrimaryItems,
showSecondaryItems,
showSubitems,
sortingMethod,
sortingMethods,
]);
const priceA = a.attributes.price useEffect(() => {
? convertPrice(a.attributes.price, commonCurrency) if (router.isReady) {
: Infinity; router.updateQuery({
const priceB = b.attributes.price page,
? convertPrice(b.attributes.price, commonCurrency) query,
: Infinity; sort: sortingMethod,
return priceA - priceB; primary: showPrimaryItems,
} secondary: showSecondaryItems,
case 2: { subitems: showSubitems,
return compareDate(a.attributes.release_date, b.attributes.release_date); status: fromLibraryItemUserStatusToString(filterUserStatus),
} });
default: }
return 0; // eslint-disable-next-line react-hooks/exhaustive-deps
} }, [
}, page,
[currencies, sortingMethod] query,
); sortingMethod,
router.isReady,
showPrimaryItems,
showSecondaryItems,
showSubitems,
filterUserStatus,
]);
const groupingFunction = useCallback( useEffect(() => {
(item: SelectiveNonNullable<Props["items"][number], "attributes" | "id">): string[] => { if (router.isReady) {
switch (groupingMethod) { if (isDefined(router.query.page)) setPage(router.query.page);
case 0: { if (isDefined(router.query.query)) setQuery(router.query.query);
const categories = filterHasAttributes(item.attributes.categories?.data, [ if (isDefined(router.query.sort)) setSortingMethod(router.query.sort);
"attributes", if (isDefined(router.query.primary)) setShowPrimaryItems(router.query.primary);
] as const); if (isDefined(router.query.secondary)) setShowSecondaryItems(router.query.secondary);
if (categories.length > 0) { if (isDefined(router.query.subitems)) setShowSubitems(router.query.subitems);
return categories.map((category) => category.attributes.name); if (isDefined(router.query.status))
} setFilterUserStatus(fromStringToLibraryItemUserStatus(router.query.status));
return [langui.no_category ?? "No category"]; }
} // eslint-disable-next-line react-hooks/exhaustive-deps
case 1: { }, [router.isReady]);
if (item.attributes.metadata && item.attributes.metadata.length > 0) {
switch (item.attributes.metadata[0]?.__typename) { useEffect(() => {
case "ComponentMetadataAudio": const totalPages = libraryItems?.totalPages;
return [langui.audio ?? "Audio"]; if (isDefined(totalPages) && totalPages < page && totalPages >= 1) setPage(totalPages);
case "ComponentMetadataGame": }, [libraryItems?.totalPages, page]);
return [langui.game ?? "Game"];
case "ComponentMetadataBooks":
return [langui.textual ?? "Textual"];
case "ComponentMetadataVideo":
return [langui.video ?? "Video"];
case "ComponentMetadataOther":
return [langui.other ?? "Other"];
case "ComponentMetadataGroup": {
switch (item.attributes.metadata[0]?.subitems_type?.data?.attributes?.slug) {
case "audio":
return [langui.audio ?? "Audio"];
case "video":
return [langui.video ?? "Video"];
case "game":
return [langui.game ?? "Game"];
case "textual":
return [langui.textual ?? "Textual"];
case "mixed":
return [langui.group ?? "Group"];
default: {
return [langui.no_type ?? "No type"];
}
}
}
default:
return [langui.no_type ?? "No type"];
}
} else {
return [langui.no_type ?? "No type"];
}
}
case 2: {
if (item.attributes.release_date?.year) {
return [item.attributes.release_date.year.toString()];
}
return [langui.no_year ?? "No year"];
}
default:
return [""];
}
},
[groupingMethod, langui]
);
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
<PanelHeader <PanelHeader
icon={Icon.LibraryBooks} icon="auto_stories"
title={langui.library} title={langui.library}
description={langui.library_description} description={langui.library_description}
/> />
@ -243,9 +258,10 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
<TextInput <TextInput
className="mb-6 w-full" className="mb-6 w-full"
placeholder={langui.search_title ?? "Search..."} placeholder={langui.search_title ?? "Search..."}
value={searchName} value={query}
onChange={(name) => { onChange={(name) => {
setSearchName(name); setPage(1);
setQuery(name);
if (isDefinedAndNotEmpty(name)) { if (isDefinedAndNotEmpty(name)) {
sendAnalytics("Library", "Change search term"); sendAnalytics("Library", "Change search term");
} else { } else {
@ -254,40 +270,17 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
}} }}
/> />
<WithLabel label={langui.group_by}>
<Select
className="w-full"
options={[
langui.category ?? "Category",
langui.type ?? "Type",
langui.release_year ?? "Year",
]}
value={groupingMethod}
onChange={(value) => {
setGroupingMethod(value);
sendAnalytics(
"Library",
`Change grouping method (${["none", "category", "type", "year"][value + 1]})`
);
}}
allowEmpty
/>
</WithLabel>
<WithLabel label={langui.order_by}> <WithLabel label={langui.order_by}>
<Select <Select
className="w-full" className="w-full"
options={[ options={sortingMethods.map((item) => item.displayedName ?? "")}
langui.name ?? "Name",
langui.price ?? "Price",
langui.release_date ?? "Release date",
]}
value={sortingMethod} value={sortingMethod}
onChange={(value) => { onChange={(newSort) => {
setSortingMethod(value); setPage(1);
setSortingMethod(newSort);
sendAnalytics( sendAnalytics(
"Library", "Library",
`Change sorting method (${["name", "price", "release date"][value]})` `Change sorting method (${sortingMethods.map((item) => item.displayedName)[newSort]})`
); );
}} }}
/> />
@ -297,6 +290,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
<Switch <Switch
value={showSubitems} value={showSubitems}
onClick={() => { onClick={() => {
setPage(1);
toggleShowSubitems(); toggleShowSubitems();
sendAnalytics("Library", `${showSubitems ? "Hide" : "Show"} subitems`); sendAnalytics("Library", `${showSubitems ? "Hide" : "Show"} subitems`);
}} }}
@ -307,6 +301,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
<Switch <Switch
value={showPrimaryItems} value={showPrimaryItems}
onClick={() => { onClick={() => {
setPage(1);
toggleShowPrimaryItems(); toggleShowPrimaryItems();
sendAnalytics("Library", `${showPrimaryItems ? "Hide" : "Show"} primary items`); sendAnalytics("Library", `${showPrimaryItems ? "Hide" : "Show"} primary items`);
}} }}
@ -317,6 +312,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
<Switch <Switch
value={showSecondaryItems} value={showSecondaryItems}
onClick={() => { onClick={() => {
setPage(1);
toggleShowSecondaryItems(); toggleShowSecondaryItems();
sendAnalytics("Library", `${showSecondaryItems ? "Hide" : "Show"} secondary items`); sendAnalytics("Library", `${showSecondaryItems ? "Hide" : "Show"} secondary items`);
}} }}
@ -340,8 +336,9 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
buttonsProps={[ buttonsProps={[
{ {
tooltip: langui.only_display_items_i_want, tooltip: langui.only_display_items_i_want,
icon: Icon.Favorite, icon: "favorite",
onClick: () => { onClick: () => {
setPage(1);
setFilterUserStatus(LibraryItemUserStatus.Want); setFilterUserStatus(LibraryItemUserStatus.Want);
sendAnalytics("Library", "Set filter status (I want)"); sendAnalytics("Library", "Set filter status (I want)");
}, },
@ -349,8 +346,9 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
}, },
{ {
tooltip: langui.only_display_items_i_have, tooltip: langui.only_display_items_i_have,
icon: Icon.BackHand, icon: "back_hand",
onClick: () => { onClick: () => {
setPage(1);
setFilterUserStatus(LibraryItemUserStatus.Have); setFilterUserStatus(LibraryItemUserStatus.Have);
sendAnalytics("Library", "Set filter status (I have)"); sendAnalytics("Library", "Set filter status (I have)");
}, },
@ -358,8 +356,9 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
}, },
{ {
tooltip: langui.only_display_unmarked_items, tooltip: langui.only_display_unmarked_items,
icon: Icon.RadioButtonUnchecked, icon: "nearby_off",
onClick: () => { onClick: () => {
setPage(1);
setFilterUserStatus(LibraryItemUserStatus.None); setFilterUserStatus(LibraryItemUserStatus.None);
sendAnalytics("Library", "Set filter status (unmarked)"); sendAnalytics("Library", "Set filter status (unmarked)");
}, },
@ -369,6 +368,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
tooltip: langui.only_display_unmarked_items, tooltip: langui.only_display_unmarked_items,
text: langui.all, text: langui.all,
onClick: () => { onClick: () => {
setPage(1);
setFilterUserStatus(undefined); setFilterUserStatus(undefined);
sendAnalytics("Library", "Set filter status (all)"); sendAnalytics("Library", "Set filter status (all)");
}, },
@ -380,14 +380,13 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
<Button <Button
className="mt-8" className="mt-8"
text={langui.reset_all_filters} text={langui.reset_all_filters}
icon={Icon.Replay} icon="settings_backup_restore"
onClick={() => { onClick={() => {
setSearchName(DEFAULT_FILTERS_STATE.searchName); setQuery(DEFAULT_FILTERS_STATE.query);
setShowSubitems(DEFAULT_FILTERS_STATE.showSubitems); setShowSubitems(DEFAULT_FILTERS_STATE.showSubitems);
setShowPrimaryItems(DEFAULT_FILTERS_STATE.showPrimaryItems); setShowPrimaryItems(DEFAULT_FILTERS_STATE.showPrimaryItems);
setShowSecondaryItems(DEFAULT_FILTERS_STATE.showSecondaryItems); setShowSecondaryItems(DEFAULT_FILTERS_STATE.showSecondaryItems);
setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod); setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod);
setGroupingMethod(DEFAULT_FILTERS_STATE.groupingMethod);
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible); setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
setFilterUserStatus(DEFAULT_FILTERS_STATE.filterUserStatus); setFilterUserStatus(DEFAULT_FILTERS_STATE.filterUserStatus);
sendAnalytics("Library", "Reset all filters"); sendAnalytics("Library", "Reset all filters");
@ -398,63 +397,54 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
const contentPanel = ( const contentPanel = (
<ContentPanel width={ContentPanelWidthSizes.Full}> <ContentPanel width={ContentPanelWidthSizes.Full}>
<SmartList <Paginator page={page} onPageChange={setPage} totalNumberOfPages={libraryItems?.totalPages}>
items={filterHasAttributes(items, ["id", "attributes"] as const)} <div
getItemId={(item) => item.id} className="grid grid-cols-[repeat(auto-fill,_minmax(12rem,1fr))] items-end
renderItem={({ item }) => ( gap-x-6 gap-y-8">
<PreviewCard {libraryItems?.hits.map((item) => (
href={`/library/${item.attributes.slug}`} <TranslatedPreviewCard
title={item.attributes.title} key={item.id}
subtitle={item.attributes.subtitle} href={`/library/${item.slug}`}
thumbnail={item.attributes.thumbnail?.data?.attributes} translations={filterHasAttributes(item._formatted.descriptions, [
thumbnailAspectRatio="21/29.7" "language.data.attributes.code",
thumbnailRounded={false} ] as const).map((translation) => ({
keepInfoVisible={keepInfoVisible} language: translation.language.data.attributes.code,
topChips={ title: item.title,
item.attributes.metadata && subtitle: item.subtitle,
item.attributes.metadata.length > 0 && description: containsHighlight(translation.description)
item.attributes.metadata[0] ? translation.description
? [prettyItemSubType(item.attributes.metadata[0])] : undefined,
: [] }))}
} fallback={{ title: item._formatted.title, subtitle: item._formatted.subtitle }}
bottomChips={item.attributes.categories?.data.map( thumbnail={item.thumbnail?.data?.attributes}
(category) => category.attributes?.short ?? "" thumbnailAspectRatio="21/29.7"
)} thumbnailRounded={false}
metadata={{ keepInfoVisible={keepInfoVisible}
releaseDate: item.attributes.release_date, topChips={
price: item.attributes.price, item.metadata && item.metadata.length > 0 && item.metadata[0]
position: "Bottom", ? [prettyItemSubType(item.metadata[0])]
}} : []
infoAppend={ }
!isUntangibleGroupItem(item.attributes.metadata?.[0]) && ( bottomChips={item.categories?.data.map(
<PreviewCardCTAs id={item.id} /> (category) => category.attributes?.short ?? ""
) )}
} metadata={{
/> releaseDate: item.release_date,
)} price: item.price,
className={cJoin( position: "Bottom",
"grid-cols-2 items-end", }}
cIf(isContentPanelAtLeast4xl, "grid-cols-[repeat(auto-fill,_minmax(13rem,1fr))]") infoAppend={
)} !isUntangibleGroupItem(item.metadata?.[0]) && <PreviewCardCTAs id={item.id} />
searchingTerm={searchName} }
sortingFunction={sortingFunction} />
groupingFunction={groupingFunction} ))}
searchingBy={(item) => </div>
prettyInlineTitle("", item.attributes.title, item.attributes.subtitle) </Paginator>
}
filteringFunction={filteringFunction}
paginationItemPerPage={25}
/>
</ContentPanel> </ContentPanel>
); );
return ( return (
<AppLayout <AppLayout subPanel={subPanel} contentPanel={contentPanel} subPanelIcon="search" {...props} />
subPanel={subPanel}
contentPanel={contentPanel}
subPanelIcon={Icon.Search}
{...otherProps}
/>
); );
}; };
export default Library; export default Library;
@ -464,19 +454,40 @@ export default Library;
* NEXT DATA FETCHING * NEXT DATA FETCHING
*/ */
export const getStaticProps: GetStaticProps = async (context) => { export const getStaticProps: GetStaticProps = (context) => {
const sdk = getReadySdk();
const langui = getLangui(context.locale); const langui = getLangui(context.locale);
const items = await sdk.getLibraryItemsPreview({
language_code: context.locale ?? "en",
});
if (!items.libraryItems?.data) return { notFound: true };
const props: Props = { const props: Props = {
items: items.libraryItems.data,
openGraph: getOpenGraph(langui, langui.library ?? "Library"), openGraph: getOpenGraph(langui, langui.library ?? "Library"),
}; };
return { return {
props: props, props: props,
}; };
}; };
const fromLibraryItemUserStatusToString = (status: LibraryItemUserStatus | undefined): string => {
switch (status) {
case LibraryItemUserStatus.None:
return "none";
case LibraryItemUserStatus.Have:
return "have";
case LibraryItemUserStatus.Want:
return "want";
default:
return "all";
}
};
const fromStringToLibraryItemUserStatus = (
status: string | undefined
): LibraryItemUserStatus | undefined => {
switch (status) {
case "none":
return LibraryItemUserStatus.None;
case "have":
return LibraryItemUserStatus.Have;
case "want":
return LibraryItemUserStatus.Want;
default:
return undefined;
}
};

View File

@ -2,7 +2,6 @@ import { GetStaticProps } from "next";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { Icon } from "components/Ico";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
@ -20,11 +19,7 @@ const Merch = (props: Props): JSX.Element => {
<AppLayout <AppLayout
subPanel={ subPanel={
<SubPanel> <SubPanel>
<PanelHeader <PanelHeader icon="store" title={langui.merch} description={langui.merch_description} />
icon={Icon.Store}
title={langui.merch}
description={langui.merch_description}
/>
</SubPanel> </SubPanel>
} }
{...props} {...props}

View File

@ -1,31 +1,35 @@
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { z } from "zod";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { GetPostsPreviewQuery } from "graphql/generated";
import { getReadySdk } from "graphql/sdk";
import { prettySlug } from "helpers/formatters";
import { Icon } from "components/Ico";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
import { TextInput } from "components/Inputs/TextInput"; import { TextInput } from "components/Inputs/TextInput";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { filterHasAttributes, isDefinedAndNotEmpty } from "helpers/asserts"; import {
import { SmartList } from "components/SmartList"; filterDefined,
filterHasAttributes,
isDefined,
isDefinedAndNotEmpty,
} from "helpers/asserts";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { compareDate } from "helpers/date";
import { TranslatedPreviewCard } from "components/PreviewCard"; import { TranslatedPreviewCard } from "components/PreviewCard";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { cIf } from "helpers/className";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
import { sendAnalytics } from "helpers/analytics"; import { sendAnalytics } from "helpers/analytics";
import { Terminal } from "components/Cli/Terminal"; import { Terminal } from "components/Cli/Terminal";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { containsHighlight, CustomSearchResponse, meiliSearch } from "helpers/search";
import { MeiliIndices, MeiliPost } from "shared/meilisearch-graphql-typings/meiliTypes";
import { useTypedRouter } from "hooks/useTypedRouter";
import { prettySlug } from "helpers/formatters";
import { Paginator } from "components/Containers/Paginator";
/* /*
* *
@ -33,43 +37,94 @@ import { useAtomGetter } from "helpers/atoms";
*/ */
const DEFAULT_FILTERS_STATE = { const DEFAULT_FILTERS_STATE = {
searchName: "", query: "",
keepInfoVisible: true, keepInfoVisible: true,
page: 1,
}; };
const queryParamSchema = z.object({
query: z.coerce.string().optional(),
page: z.coerce.number().positive().optional(),
});
/* /*
* *
* PAGE * PAGE
*/ */
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {}
posts: NonNullable<GetPostsPreviewQuery["posts"]>["data"];
}
const News = ({ posts, ...otherProps }: Props): JSX.Element => { const News = ({ ...otherProps }: Props): JSX.Element => {
const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl);
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
const hoverable = useDeviceSupportsHover(); const hoverable = useDeviceSupportsHover();
const [searchName, setSearchName] = useState(DEFAULT_FILTERS_STATE.searchName); const router = useTypedRouter(queryParamSchema);
const [query, setQuery] = useState(router.query.query ?? DEFAULT_FILTERS_STATE.query);
const { const {
value: keepInfoVisible, value: keepInfoVisible,
toggle: toggleKeepInfoVisible, toggle: toggleKeepInfoVisible,
setValue: setKeepInfoVisible, setValue: setKeepInfoVisible,
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible); } = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
const isTerminalMode = useAtomGetter(atoms.layout.terminalMode); const isTerminalMode = useAtomGetter(atoms.layout.terminalMode);
const [page, setPage] = useState<number>(router.query.page ?? DEFAULT_FILTERS_STATE.page);
const [posts, setPosts] = useState<CustomSearchResponse<MeiliPost>>();
useEffect(() => {
const fetchPosts = async () => {
const searchResult = await meiliSearch(MeiliIndices.POST, query, {
hitsPerPage: 25,
page,
attributesToRetrieve: ["translations", "thumbnail", "slug", "date", "categories"],
attributesToHighlight: ["translations.title", "translations.excerpt", "translations.body"],
attributesToCrop: ["translations.body"],
sort: ["sortable_date:desc"],
filter: ["hidden = false"],
});
searchResult.hits = searchResult.hits.map((item) => {
if (Object.keys(item._matchesPosition).some((match) => match.startsWith("translations"))) {
item._formatted.translations = filterDefined(item._formatted.translations).filter(
(translation) => JSON.stringify(translation).includes("</mark>")
);
}
return item;
});
setPosts(searchResult);
};
fetchPosts();
}, [query, page]);
useEffect(() => {
if (router.isReady)
router.updateQuery({
page,
query,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, query, router.isReady]);
useEffect(() => {
if (router.isReady) {
if (isDefined(router.query.page)) setPage(router.query.page);
if (isDefined(router.query.query)) setQuery(router.query.query);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]);
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
<PanelHeader icon={Icon.Feed} title={langui.news} description={langui.news_description} /> <PanelHeader icon="newspaper" title={langui.news} description={langui.news_description} />
<HorizontalLine /> <HorizontalLine />
<TextInput <TextInput
className="mb-6 w-full" className="mb-6 w-full"
placeholder={langui.search_title ?? "Search..."} placeholder={langui.search_title ?? "Search..."}
value={searchName} value={query}
onChange={(name) => { onChange={(name) => {
setSearchName(name); setQuery(name);
if (isDefinedAndNotEmpty(name)) { if (isDefinedAndNotEmpty(name)) {
sendAnalytics("News", "Change search term"); sendAnalytics("News", "Change search term");
} else { } else {
@ -93,9 +148,9 @@ const News = ({ posts, ...otherProps }: Props): JSX.Element => {
<Button <Button
className="mt-8" className="mt-8"
text={langui.reset_all_filters} text={langui.reset_all_filters}
icon={Icon.Replay} icon="settings_backup_restore"
onClick={() => { onClick={() => {
setSearchName(DEFAULT_FILTERS_STATE.searchName); setQuery(DEFAULT_FILTERS_STATE.query);
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible); setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
sendAnalytics("News", "Reset all filters"); sendAnalytics("News", "Reset all filters");
}} }}
@ -105,66 +160,54 @@ const News = ({ posts, ...otherProps }: Props): JSX.Element => {
const contentPanel = ( const contentPanel = (
<ContentPanel width={ContentPanelWidthSizes.Full}> <ContentPanel width={ContentPanelWidthSizes.Full}>
<SmartList <Paginator page={page} onPageChange={setPage} totalNumberOfPages={posts?.totalPages}>
items={filterHasAttributes(posts, ["attributes", "id"] as const)} <div
getItemId={(post) => post.id} className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
renderItem={({ item: post }) => ( gap-x-6 gap-y-8">
<TranslatedPreviewCard {posts?.hits.map((item) => (
href={`/news/${post.attributes.slug}`} <TranslatedPreviewCard
translations={filterHasAttributes(post.attributes.translations, [ key={item.id}
"language.data.attributes.code", href={`/news/${item.slug}`}
] as const).map((translation) => ({ translations={filterHasAttributes(item._formatted.translations, [
language: translation.language.data.attributes.code, "language.data.attributes.code",
title: translation.title, ] as const).map(({ excerpt, body, language, ...otherAttributes }) => ({
description: translation.excerpt, ...otherAttributes,
}))} description: containsHighlight(excerpt)
fallback={{ title: prettySlug(post.attributes.slug) }} ? excerpt
thumbnail={post.attributes.thumbnail?.data?.attributes} : containsHighlight(body)
thumbnailAspectRatio="3/2" ? body
thumbnailForceAspectRatio : excerpt,
bottomChips={post.attributes.categories?.data.map( language: language.data.attributes.code,
(category) => category.attributes?.short ?? "" }))}
)} fallback={{ title: prettySlug(item.slug) }}
keepInfoVisible={keepInfoVisible} thumbnail={item.thumbnail?.data?.attributes}
metadata={{ thumbnailAspectRatio="3/2"
releaseDate: post.attributes.date, thumbnailForceAspectRatio
releaseDateFormat: "long", keepInfoVisible={keepInfoVisible}
position: "Top", bottomChips={item.categories?.data.map(
}} (category) => category.attributes?.short ?? ""
/> )}
)} metadata={{
className={cIf( releaseDate: item.date,
isContentPanelAtLeast4xl, releaseDateFormat: "long",
"grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] gap-x-6 gap-y-8", position: "Top",
"grid-cols-2 gap-x-4 gap-y-6" }}
)} />
searchingTerm={searchName} ))}
searchingBy={(post) => </div>
`${prettySlug(post.attributes.slug)} ${post.attributes.translations </Paginator>
?.map((translation) => translation?.title)
.join(" ")}`
}
paginationItemPerPage={25}
/>
</ContentPanel> </ContentPanel>
); );
if (isTerminalMode) { if (isTerminalMode) {
return ( return <Terminal parentPath="/" childrenPaths={posts?.hits.map((post) => post.slug) ?? []} />;
<Terminal
parentPath="/"
childrenPaths={filterHasAttributes(posts, ["attributes"] as const).map(
(post) => post.attributes.slug
)}
/>
);
} }
return ( return (
<AppLayout <AppLayout
subPanel={subPanel} subPanel={subPanel}
contentPanel={contentPanel} contentPanel={contentPanel}
subPanelIcon={Icon.Search} subPanelIcon="search"
{...otherProps} {...otherProps}
/> />
); );
@ -176,25 +219,12 @@ export default News;
* NEXT DATA FETCHING * NEXT DATA FETCHING
*/ */
export const getStaticProps: GetStaticProps = async (context) => { export const getStaticProps: GetStaticProps = (context) => {
const sdk = getReadySdk();
const langui = getLangui(context.locale); const langui = getLangui(context.locale);
const posts = await sdk.getPostsPreview();
if (!posts.posts) return { notFound: true };
const props: Props = { const props: Props = {
posts: sortPosts(posts.posts.data),
openGraph: getOpenGraph(langui, langui.news ?? "News"), openGraph: getOpenGraph(langui, langui.news ?? "News"),
}; };
return { return {
props: props, props: props,
}; };
}; };
/*
*
* PRIVATE METHODS
*/
const sortPosts = (posts: Props["posts"]): Props["posts"] =>
posts.sort((a, b) => compareDate(a.attributes?.date, b.attributes?.date)).reverse();

View File

@ -146,7 +146,7 @@ const WikiPage = ({ page, ...otherProps }: Props): JSX.Element => {
"/contents/", "/contents/",
definition.source.data.attributes.content.data.attributes.slug definition.source.data.attributes.content.data.attributes.slug
) )
: cJoin( : sJoin(
"/library/", "/library/",
definition.source?.data?.attributes?.ranged_content?.data?.attributes definition.source?.data?.attributes?.ranged_content?.data?.attributes
?.library_item?.data?.attributes?.slug ?.library_item?.data?.attributes?.slug
@ -165,6 +165,10 @@ const WikiPage = ({ page, ...otherProps }: Props): JSX.Element => {
</div> </div>
) )
)} )}
{isDefined(selectedTranslation.body) && (
<div className="whitespace-pre-line">{selectedTranslation.body.body}</div>
)}
</div> </div>
</> </>
)} )}

View File

@ -19,7 +19,7 @@ import { getOpenGraph } from "helpers/openGraph";
import { useSmartLanguage } from "hooks/useSmartLanguage"; import { useSmartLanguage } from "hooks/useSmartLanguage";
import { ToolTip } from "components/ToolTip"; import { ToolTip } from "components/ToolTip";
import { Chip } from "components/Chip"; import { Chip } from "components/Chip";
import { Ico, Icon } from "components/Ico"; import { Ico } from "components/Ico";
import { AnchorShare } from "components/AnchorShare"; import { AnchorShare } from "components/AnchorShare";
import { datePickerToDate } from "helpers/date"; import { datePickerToDate } from "helpers/date";
import { TranslatedProps } from "types/TranslatedProps"; import { TranslatedProps } from "types/TranslatedProps";
@ -333,7 +333,7 @@ export const ChronologyEvent = ({ event, id }: ChronologyEventProps): JSX.Elemen
`(${event.source.data.attributes?.name})` `(${event.source.data.attributes?.name})`
) : ( ) : (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Ico icon={Icon.Warning} className="!text-sm" /> <Ico icon="warning" className="!text-sm" />
{langui.no_source_warning} {langui.no_source_warning}
</div> </div>
)} )}

View File

@ -1,13 +1,11 @@
import { GetStaticProps } from "next"; import { GetStaticProps } from "next";
import { useCallback, useState } from "react"; import { useEffect, useState } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { z } from "zod";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { NavOption } from "components/PanelComponents/NavOption"; import { NavOption } from "components/PanelComponents/NavOption";
import { PanelHeader } from "components/PanelComponents/PanelHeader"; import { PanelHeader } from "components/PanelComponents/PanelHeader";
import { SubPanel } from "components/Containers/SubPanel"; import { SubPanel } from "components/Containers/SubPanel";
import { Icon } from "components/Ico";
import { getReadySdk } from "graphql/sdk";
import { GetWikiPageQuery, GetWikiPagesPreviewsQuery } from "graphql/generated";
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel"; import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
import { HorizontalLine } from "components/HorizontalLine"; import { HorizontalLine } from "components/HorizontalLine";
import { Button } from "components/Inputs/Button"; import { Button } from "components/Inputs/Button";
@ -15,23 +13,18 @@ import { Switch } from "components/Inputs/Switch";
import { TextInput } from "components/Inputs/TextInput"; import { TextInput } from "components/Inputs/TextInput";
import { WithLabel } from "components/Inputs/WithLabel"; import { WithLabel } from "components/Inputs/WithLabel";
import { useDeviceSupportsHover } from "hooks/useMediaQuery"; import { useDeviceSupportsHover } from "hooks/useMediaQuery";
import { import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
filterDefined,
filterHasAttributes,
isDefinedAndNotEmpty,
SelectiveNonNullable,
} from "helpers/asserts";
import { SmartList } from "components/SmartList";
import { Select } from "components/Inputs/Select";
import { prettySlug } from "helpers/formatters"; import { prettySlug } from "helpers/formatters";
import { getOpenGraph } from "helpers/openGraph"; import { getOpenGraph } from "helpers/openGraph";
import { TranslatedPreviewCard } from "components/PreviewCard"; import { TranslatedPreviewCard } from "components/PreviewCard";
import { cIf } from "helpers/className";
import { getLangui } from "graphql/fetchLocalData"; import { getLangui } from "graphql/fetchLocalData";
import { sendAnalytics } from "helpers/analytics"; import { sendAnalytics } from "helpers/analytics";
import { Terminal } from "components/Cli/Terminal";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { useTypedRouter } from "hooks/useTypedRouter";
import { MeiliIndices, MeiliWikiPage } from "shared/meilisearch-graphql-typings/meiliTypes";
import { containsHighlight, CustomSearchResponse, meiliSearch } from "helpers/search";
import { Paginator } from "components/Containers/Paginator";
/* /*
* *
@ -39,31 +32,28 @@ import { useAtomGetter } from "helpers/atoms";
*/ */
const DEFAULT_FILTERS_STATE = { const DEFAULT_FILTERS_STATE = {
searchName: "", query: "",
keepInfoVisible: true, keepInfoVisible: true,
groupingMethod: -1, page: 1,
}; };
const queryParamSchema = z.object({
query: z.coerce.string().optional(),
page: z.coerce.number().positive().optional(),
});
/* /*
* *
* PAGE * PAGE
*/ */
interface Props extends AppLayoutRequired { interface Props extends AppLayoutRequired {}
pages: NonNullable<GetWikiPagesPreviewsQuery["wikiPages"]>["data"];
}
const Wiki = ({ pages, ...otherProps }: Props): JSX.Element => { const Wiki = (props: Props): JSX.Element => {
const hoverable = useDeviceSupportsHover(); const hoverable = useDeviceSupportsHover();
const langui = useAtomGetter(atoms.localData.langui); const langui = useAtomGetter(atoms.localData.langui);
const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl); const router = useTypedRouter(queryParamSchema);
const isTerminalMode = useAtomGetter(atoms.layout.terminalMode); const [query, setQuery] = useState(router.query.query ?? DEFAULT_FILTERS_STATE.query);
const [searchName, setSearchName] = useState(DEFAULT_FILTERS_STATE.searchName);
const [groupingMethod, setGroupingMethod] = useState<number>(
DEFAULT_FILTERS_STATE.groupingMethod
);
const { const {
value: keepInfoVisible, value: keepInfoVisible,
@ -71,10 +61,48 @@ const Wiki = ({ pages, ...otherProps }: Props): JSX.Element => {
setValue: setKeepInfoVisible, setValue: setKeepInfoVisible,
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible); } = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
const [page, setPage] = useState<number>(router.query.page ?? DEFAULT_FILTERS_STATE.page);
const [wikiPages, setWikiPages] = useState<CustomSearchResponse<MeiliWikiPage>>();
useEffect(() => {
const fetchWikiPages = async () => {
const searchResult = await meiliSearch(MeiliIndices.WIKI_PAGE, query, {
hitsPerPage: 25,
page,
attributesToHighlight: [
"translations.title",
"translations.aliases",
"translations.summary",
"translations.displayable_description",
],
attributesToCrop: ["translations.displayable_description"],
});
setWikiPages(searchResult);
};
fetchWikiPages();
}, [query, page]);
useEffect(() => {
if (router.isReady)
router.updateQuery({
page,
query,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, query, router.isReady]);
useEffect(() => {
if (router.isReady) {
if (isDefined(router.query.page)) setPage(router.query.page);
if (isDefined(router.query.query)) setQuery(router.query.query);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]);
const subPanel = ( const subPanel = (
<SubPanel> <SubPanel>
<PanelHeader <PanelHeader
icon={Icon.TravelExplore} icon="travel_explore"
title={langui.wiki} title={langui.wiki}
description={langui.wiki_description} description={langui.wiki_description}
/> />
@ -84,9 +112,10 @@ const Wiki = ({ pages, ...otherProps }: Props): JSX.Element => {
<TextInput <TextInput
className="mb-6 w-full" className="mb-6 w-full"
placeholder={langui.search_title ?? "Search..."} placeholder={langui.search_title ?? "Search..."}
value={searchName} value={query}
onChange={(name) => { onChange={(name) => {
setSearchName(name); setPage(1);
setQuery(name);
if (isDefinedAndNotEmpty(name)) { if (isDefinedAndNotEmpty(name)) {
sendAnalytics("Wiki", "Change search term"); sendAnalytics("Wiki", "Change search term");
} else { } else {
@ -95,19 +124,6 @@ const Wiki = ({ pages, ...otherProps }: Props): JSX.Element => {
}} }}
/> />
<WithLabel label={langui.group_by}>
<Select
className="w-full"
options={[langui.category ?? "Category"]}
value={groupingMethod}
onChange={(value) => {
setGroupingMethod(value);
sendAnalytics("Wiki", `Change grouping method (${["none", "category"][value + 1]})`);
}}
allowEmpty
/>
</WithLabel>
{hoverable && ( {hoverable && (
<WithLabel label={langui.always_show_info}> <WithLabel label={langui.always_show_info}>
<Switch <Switch
@ -123,10 +139,10 @@ const Wiki = ({ pages, ...otherProps }: Props): JSX.Element => {
<Button <Button
className="mt-8" className="mt-8"
text={langui.reset_all_filters} text={langui.reset_all_filters}
icon={Icon.Replay} icon="settings_backup_restore"
onClick={() => { onClick={() => {
setSearchName(DEFAULT_FILTERS_STATE.searchName); setPage(1);
setGroupingMethod(DEFAULT_FILTERS_STATE.groupingMethod); setQuery(DEFAULT_FILTERS_STATE.query);
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible); setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
sendAnalytics("Wiki", "Reset all filters"); sendAnalytics("Wiki", "Reset all filters");
}} }}
@ -140,104 +156,52 @@ const Wiki = ({ pages, ...otherProps }: Props): JSX.Element => {
</SubPanel> </SubPanel>
); );
const groupingFunction = useCallback(
(
item: SelectiveNonNullable<
NonNullable<GetWikiPageQuery["wikiPages"]>["data"][number],
"attributes" | "id"
>
): string[] => {
switch (groupingMethod) {
case 0: {
const categories = filterHasAttributes(item.attributes.categories?.data, [
"attributes",
] as const);
if (categories.length > 0) {
return categories.map((category) => category.attributes.name);
}
return [langui.no_category ?? "No category"];
}
default: {
return [""];
}
}
},
[groupingMethod, langui]
);
const contentPanel = ( const contentPanel = (
<ContentPanel width={ContentPanelWidthSizes.Full}> <ContentPanel width={ContentPanelWidthSizes.Full}>
<SmartList <Paginator page={page} onPageChange={setPage} totalNumberOfPages={wikiPages?.totalPages}>
items={filterHasAttributes(pages, ["id", "attributes"] as const)} <div
getItemId={(item) => item.id} className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
renderItem={({ item }) => ( gap-x-6 gap-y-8">
<TranslatedPreviewCard {wikiPages?.hits.map((item) => (
href={`/wiki/${item.attributes.slug}`} <TranslatedPreviewCard
translations={filterHasAttributes(item.attributes.translations, [ key={item.id}
"language.data.attributes.code", href={`/wiki/${item.slug}`}
] as const).map((translation) => ({ translations={filterHasAttributes(item._formatted.translations, [
title: translation.title, "language.data.attributes.code",
subtitle: ] as const).map(
translation.aliases && translation.aliases.length > 0 ({ aliases, summary, displayable_description, language, ...otherAttributes }) => ({
? translation.aliases.map((alias) => alias?.alias).join("・") ...otherAttributes,
: undefined, subtitle:
description: translation.summary, aliases && aliases.length > 0
language: translation.language.data.attributes.code, ? aliases.map((alias) => alias?.alias).join("・")
}))} : undefined,
fallback={{ title: prettySlug(item.attributes.slug) }} description: containsHighlight(displayable_description)
thumbnail={item.attributes.thumbnail?.data?.attributes} ? displayable_description
thumbnailAspectRatio={"4/3"} : summary,
thumbnailRounded language: language.data.attributes.code,
thumbnailForceAspectRatio })
keepInfoVisible={keepInfoVisible} )}
topChips={filterHasAttributes(item.attributes.tags?.data, ["attributes"] as const).map( fallback={{ title: prettySlug(item.slug) }}
(tag) => tag.attributes.titles?.[0]?.title ?? prettySlug(tag.attributes.slug) thumbnail={item.thumbnail?.data?.attributes}
)} thumbnailAspectRatio={"4/3"}
bottomChips={filterHasAttributes(item.attributes.categories?.data, [ thumbnailRounded
"attributes", thumbnailForceAspectRatio
] as const).map((category) => category.attributes.short)} keepInfoVisible
/> topChips={filterHasAttributes(item.tags?.data, ["attributes"] as const).map(
)} (tag) => tag.attributes.titles?.[0]?.title ?? prettySlug(tag.attributes.slug)
className={cIf( )}
isContentPanelAtLeast4xl, bottomChips={filterHasAttributes(item.categories?.data, ["attributes"] as const).map(
"grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-x-6 gap-y-8", (category) => category.attributes.short
"grid-cols-2 gap-x-3 gap-y-5" )}
)} />
searchingTerm={searchName} ))}
searchingBy={(item) => </div>
filterDefined(item.attributes.translations) </Paginator>
.map(
(translation) =>
`${translation.title} ${filterDefined(translation.aliases)
.map((alias) => alias.alias)
.join(" ")}`
)
.join(" ")
}
groupingFunction={groupingFunction}
paginationItemPerPage={25}
/>
</ContentPanel> </ContentPanel>
); );
if (isTerminalMode) {
return (
<Terminal
parentPath="/"
childrenPaths={filterHasAttributes(pages, ["attributes"] as const).map(
(page) => page.attributes.slug
)}
/>
);
}
return ( return (
<AppLayout <AppLayout subPanel={subPanel} contentPanel={contentPanel} subPanelIcon="search" {...props} />
subPanel={subPanel}
contentPanel={contentPanel}
subPanelIcon={Icon.Search}
{...otherProps}
/>
); );
}; };
export default Wiki; export default Wiki;
@ -247,31 +211,12 @@ export default Wiki;
* NEXT DATA FETCHING * NEXT DATA FETCHING
*/ */
export const getStaticProps: GetStaticProps = async (context) => { export const getStaticProps: GetStaticProps = (context) => {
const sdk = getReadySdk();
const langui = getLangui(context.locale); const langui = getLangui(context.locale);
const pages = await sdk.getWikiPagesPreviews({
language_code: context.locale ?? "en",
});
if (!pages.wikiPages?.data) return { notFound: true };
const props: Props = { const props: Props = {
pages: sortPages(pages.wikiPages.data),
openGraph: getOpenGraph(langui, langui.wiki ?? "Wiki"), openGraph: getOpenGraph(langui, langui.wiki ?? "Wiki"),
}; };
return { return {
props: props, props: props,
}; };
}; };
/*
*
* PRIVATE METHODS
*/
const sortPages = (pages: Props["pages"]): Props["pages"] =>
pages.sort((a, b) => {
const slugA = a.attributes?.slug ?? "";
const slugB = b.attributes?.slug ?? "";
return slugA.localeCompare(slugB);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,88 @@
import {
ContentAttributesFragment,
GetContentQuery,
GetLibraryItemQuery,
GetPostQuery,
GetVideoQuery,
GetWikiPageQuery,
LibraryItemAttributesFragment,
PostAttributesFragment,
VideoAttributesFragment,
WikiPageAttributesFragment,
} from "./generated";
export interface MeiliLibraryItem extends LibraryItemAttributesFragment {
id: string;
sortable_name: string;
sortable_price: number | undefined;
sortable_date: number | undefined;
untangible_group_item: boolean;
}
export interface MeiliContent
extends Omit<ContentAttributesFragment, "translations" | "updatedAt"> {
id: string;
translations: (Omit<
NonNullable<NonNullable<ContentAttributesFragment["translations"]>[number]>,
"text_set" | "description"
> & {
displayable_description?: string | null;
})[];
sortable_updated_date: number;
}
export interface MeiliVideo extends VideoAttributesFragment {
id: string;
sortable_published_date: number;
channel_uid?: string;
}
export interface MeiliPost extends PostAttributesFragment {
id: string;
sortable_date: number;
}
export interface MeiliWikiPage extends Omit<WikiPageAttributesFragment, "translations"> {
id: string;
translations: (Omit<
NonNullable<NonNullable<WikiPageAttributesFragment["translations"]>[number]>,
"body"
> & {
displayable_description?: string | null;
})[];
}
export enum MeiliIndices {
LIBRARY_ITEM = "library-item",
CONTENT = "content",
VIDEOS = "video",
POST = "post",
WIKI_PAGE = "wiki-page",
}
export type MeiliDocumentsType =
| {
index: MeiliIndices.LIBRARY_ITEM;
documents: MeiliLibraryItem;
strapi: GetLibraryItemQuery["libraryItem"];
}
| {
index: MeiliIndices.CONTENT;
documents: MeiliContent;
strapi: GetContentQuery["content"];
}
| {
index: MeiliIndices.VIDEOS;
documents: MeiliVideo;
strapi: GetVideoQuery["video"];
}
| {
index: MeiliIndices.POST;
documents: MeiliPost;
strapi: GetPostQuery["post"];
}
| {
index: MeiliIndices.WIKI_PAGE;
documents: MeiliWikiPage;
strapi: GetWikiPageQuery["wikiPage"];
};

View File

@ -23,8 +23,14 @@ h6 {
@apply bg-dark text-light; @apply bg-dark text-light;
} }
/* MARKS */
mark { mark {
@apply bg-mid px-2; @apply -mx-1 inline-block bg-mid px-1 text-black dark:bg-dark/50;
}
mark + mark {
@apply ml-0 pl-0;
} }
/* INPUT */ /* INPUT */

View File

@ -5,7 +5,7 @@ const rgb = (color) => [color.r, color.g, color.b].join(" ");
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: "class", darkMode: ["class", ".set-theme-dark"],
content: ["./src/**/*.{tsx,ts}"], content: ["./src/**/*.{tsx,ts}"],
theme: { theme: {
colors: { colors: {