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:
parent
0ddd46643b
commit
dd3beff508
|
@ -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
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"upgrade": false,
|
||||||
|
"reject": ["react-hotkeys-hook"]
|
||||||
|
}
|
|
@ -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.
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
50
package.json
50
package.json
|
@ -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": {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -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}`
|
||||||
|
|
|
@ -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 => (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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) => (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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 ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)) }}
|
||||||
|
/>
|
||||||
|
);
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
|
@ -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",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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} />
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>");
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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, [
|
||||||
|
|
|
@ -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’s name placeholder</h3>}>
|
content={<h3 className="text-lg">Player’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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -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)]",
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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();
|
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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
|
@ -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"];
|
||||||
|
};
|
|
@ -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 */
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Loading…
Reference in New Issue