Added search on most pages
This commit is contained in:
parent
0ddd46643b
commit
46535e7973
@ -1,4 +1,5 @@
|
||||
src/graphql/generated.ts
|
||||
src/shared
|
||||
.eslintrc.js
|
||||
graphql-codegen.config.js
|
||||
next-env.d.ts
|
||||
|
4
.ncurc.json
Normal file
4
.ncurc.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"upgrade": false,
|
||||
"reject": ["@types/react", "react-hotkeys-hook"]
|
||||
}
|
1856
package-lock.json
generated
1856
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@ -26,9 +26,11 @@
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cuid": "^2.1.8",
|
||||
"graphql-request": "^5.0.0",
|
||||
"jotai": "^1.11.0",
|
||||
"markdown-to-jsx": "^7.1.7",
|
||||
"isomorphic-dompurify": "^0.24.0",
|
||||
"jotai": "^1.11.2",
|
||||
"markdown-to-jsx": "^7.1.8",
|
||||
"marked": "^4.2.5",
|
||||
"meilisearch": "^0.30.0",
|
||||
"next": "^13.0.6",
|
||||
"nodemailer": "^6.8.0",
|
||||
"rc-slider": "^10.1.0",
|
||||
@ -42,15 +44,17 @@
|
||||
"tippy.js": "^6.3.7",
|
||||
"turndown": "^7.1.1",
|
||||
"ua-parser-js": "^1.0.32",
|
||||
"usehooks-ts": "^2.9.1"
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@digitak/esrun": "^3.2.15",
|
||||
"@graphql-codegen/cli": "^2.15.0",
|
||||
"@graphql-codegen/typescript": "2.8.3",
|
||||
"@graphql-codegen/cli": "^2.16.1",
|
||||
"@graphql-codegen/typescript": "2.8.5",
|
||||
"@graphql-codegen/typescript-graphql-request": "^4.5.8",
|
||||
"@graphql-codegen/typescript-operations": "^2.5.8",
|
||||
"@types/node": "18.11.10",
|
||||
"@graphql-codegen/typescript-operations": "^2.5.10",
|
||||
"@types/marked": "^4.0.8",
|
||||
"@types/node": "18.11.14",
|
||||
"@types/nodemailer": "^6.4.6",
|
||||
"@types/react": "^18.0.22",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
@ -58,19 +62,20 @@
|
||||
"@types/throttle-debounce": "^5.0.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.46.1",
|
||||
"@typescript-eslint/parser": "^5.46.1",
|
||||
"dotenv": "^16.0.3",
|
||||
"eslint": "^8.29.0",
|
||||
"eslint-config-next": "13.0.6",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^5.0.0",
|
||||
"next-sitemap": "^3.1.32",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-tailwindcss": "^0.2.0",
|
||||
"prettier": "^2.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.2.1",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"ts-unused-exports": "^8.0.0",
|
||||
"typescript": "^4.9.3"
|
||||
"ts-unused-exports": "^8.0.5",
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"overrides": {
|
||||
"react-zoom-pan-pinch": {
|
||||
|
@ -185,7 +185,15 @@
|
||||
"double_page_view": "Double page view",
|
||||
"reset_all_options": "Reset all options",
|
||||
"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",
|
||||
"reset_all_options": "Réinitialiser les options",
|
||||
"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,
|
||||
"reset_all_options": 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,
|
||||
"reset_all_options": 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,
|
||||
"reset_all_options": 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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
63
src/components/Containers/Paginator.tsx
Normal file
63
src/components/Containers/Paginator.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { Ico, Icon } 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={Icon.ChevronLeft} className="!text-[300%]" />}
|
||||
<p className="max-w-xs text-2xl">{langui.no_results_message}</p>
|
||||
{!is3ColumnsLayout && <Ico icon={Icon.ChevronRight} className="!text-[300%]" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -3,6 +3,8 @@ import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomSetter } from "helpers/atoms";
|
||||
import { Button } from "components/Inputs/Button";
|
||||
import { Icon } from "components/Ico";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
@ -16,6 +18,7 @@ interface Props {
|
||||
fillViewport?: boolean;
|
||||
hideBackground?: boolean;
|
||||
padding?: boolean;
|
||||
withCloseButton?: boolean;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
@ -27,10 +30,11 @@ export const Popup = ({
|
||||
fillViewport,
|
||||
hideBackground = false,
|
||||
padding = true,
|
||||
withCloseButton = true,
|
||||
}: Props): JSX.Element => {
|
||||
const setMenuGesturesEnabled = useAtomSetter(atoms.layout.menuGesturesEnabled);
|
||||
|
||||
useHotkeys("escape", () => onCloseRequest?.(), {}, [onCloseRequest]);
|
||||
useHotkeys("escape", () => onCloseRequest?.(), { enabled: isVisible }, [onCloseRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuGesturesEnabled(!isVisible);
|
||||
@ -55,9 +59,18 @@ export const Popup = ({
|
||||
"grid place-items-center gap-4 transition-transform",
|
||||
cIf(padding, "p-10"),
|
||||
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")
|
||||
)}>
|
||||
{withCloseButton && (
|
||||
<div className="absolute right-6 top-6">
|
||||
<Button icon={Icon.Close} onClick={onCloseRequest} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,24 +21,24 @@ export const PageSelector = ({ page, className, pagesCount, onChange }: Props):
|
||||
className={cJoin("flex flex-row place-content-center", className)}
|
||||
buttonsProps={[
|
||||
{
|
||||
onClick: () => onChange(0),
|
||||
disabled: page === 0,
|
||||
onClick: () => onChange(1),
|
||||
disabled: page === 1,
|
||||
icon: Icon.FirstPage,
|
||||
},
|
||||
{
|
||||
onClick: () => page > 0 && onChange(page - 1),
|
||||
disabled: page === 0,
|
||||
onClick: () => page > 1 && onChange(page - 1),
|
||||
disabled: page === 1,
|
||||
icon: Icon.NavigateBefore,
|
||||
},
|
||||
{ text: `${page + 1} / ${pagesCount}` },
|
||||
{ text: `${page} / ${pagesCount}` },
|
||||
{
|
||||
onClick: () => page < pagesCount - 1 && onChange(page + 1),
|
||||
disabled: page === pagesCount - 1,
|
||||
onClick: () => page < pagesCount && onChange(page + 1),
|
||||
disabled: page === pagesCount,
|
||||
icon: Icon.NavigateNext,
|
||||
},
|
||||
{
|
||||
onClick: () => onChange(pagesCount - 1),
|
||||
disabled: page === pagesCount - 1,
|
||||
onClick: () => onChange(pagesCount),
|
||||
disabled: page === pagesCount,
|
||||
icon: Icon.LastPage,
|
||||
},
|
||||
]}
|
||||
|
@ -12,7 +12,7 @@ interface Props {
|
||||
onChange: (newValue: string) => void;
|
||||
className?: string;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
placeholder?: string | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export const TextInput = ({
|
||||
name={name}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
placeholder={placeholder ?? undefined}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
|
21
src/components/Markdown/Markdown.tsx
Normal file
21
src/components/Markdown/Markdown.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { marked } from "marked";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ───────────────────────────────────────╯ COMPONENT ╰───────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface MarkdownProps {
|
||||
className?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
|
||||
export const Markdown = ({ className, text }: MarkdownProps): JSX.Element => (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(text)) }}
|
||||
/>
|
||||
);
|
@ -22,6 +22,7 @@ export const MainPanel = (): JSX.Element => {
|
||||
const langui = useAtomGetter(atoms.localData.langui);
|
||||
const [isMainPanelReduced, setMainPanelReduced] = useAtomPair(atoms.layout.mainPanelReduced);
|
||||
const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened);
|
||||
const setSearchOpened = useAtomSetter(atoms.layout.searchOpened);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -84,6 +85,19 @@ export const MainPanel = (): JSX.Element => {
|
||||
icon={Icon.Settings}
|
||||
/>
|
||||
</ToolTip>
|
||||
<ToolTip
|
||||
content={<h3 className="text-2xl">{langui.open_search}</h3>}
|
||||
placement="right"
|
||||
className="text-left"
|
||||
disabled={!isMainPanelReduced}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchOpened(true);
|
||||
sendAnalytics("Search", "Open search");
|
||||
}}
|
||||
icon={Icon.Search}
|
||||
/>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
322
src/components/Panels/SearchPopup.tsx
Normal file
322
src/components/Panels/SearchPopup.tsx
Normal file
@ -0,0 +1,322 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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,
|
||||
} from "shared/meilisearch-graphql-typings/meiliTypes";
|
||||
import { getVideoThumbnailURL } from "helpers/videos";
|
||||
import { UpPressable } from "components/Containers/UpPressable";
|
||||
import { prettyItemSubType, prettySlug } from "helpers/formatters";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
* ────────────────────────────────────────╯ 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>>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLibraryItems = async () => {
|
||||
const searchResult = await meiliSearch(MeiliIndices.LIBRARY_ITEM, query, {
|
||||
limit: SEARCH_LIMIT,
|
||||
attributesToCrop: ["descriptions"],
|
||||
});
|
||||
searchResult.hits = searchResult.hits.map((item) => {
|
||||
if (
|
||||
isDefined(item._formatted) &&
|
||||
item._matchesPosition.descriptions &&
|
||||
item._matchesPosition.descriptions.length > 0
|
||||
) {
|
||||
item._formatted.descriptions = filterDefined(item._formatted.descriptions).filter(
|
||||
(description) => description.includes("</mark>")
|
||||
);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
setLibraryItems(searchResult);
|
||||
};
|
||||
|
||||
const fetchContents = async () => {
|
||||
const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, {
|
||||
attributesToCrop: ["translations.description"],
|
||||
limit: SEARCH_LIMIT,
|
||||
});
|
||||
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;
|
||||
});
|
||||
setContents(searchResult);
|
||||
};
|
||||
|
||||
const fetchVideos = async () => {
|
||||
const searchResult = await meiliSearch(MeiliIndices.VIDEOS, query, {
|
||||
limit: SEARCH_LIMIT,
|
||||
attributesToRetrieve: [
|
||||
"title",
|
||||
"channel",
|
||||
"uid",
|
||||
"published_date",
|
||||
"views",
|
||||
"duration",
|
||||
"description",
|
||||
],
|
||||
attributesToCrop: ["description"],
|
||||
sort: ["sortable_published_date:desc"],
|
||||
});
|
||||
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"],
|
||||
sort: ["sortable_date:desc"],
|
||||
filter: ["hidden = false"],
|
||||
});
|
||||
setPosts(searchResult);
|
||||
};
|
||||
|
||||
if (query === "") {
|
||||
setLibraryItems(undefined);
|
||||
setContents(undefined);
|
||||
setVideos(undefined);
|
||||
setPosts(undefined);
|
||||
} else {
|
||||
fetchLibraryItems();
|
||||
fetchContents();
|
||||
fetchVideos();
|
||||
fetchPosts();
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
isVisible={isSearchOpened}
|
||||
onCloseRequest={() => {
|
||||
setSearchOpened(false);
|
||||
sendAnalytics("Search", "Close search");
|
||||
}}
|
||||
fillViewport>
|
||||
<h2 className="text-2xl">{langui.search}</h2>
|
||||
<TextInput onChange={setQuery} value={query} placeholder={langui.search_title} />
|
||||
|
||||
<div className="flex flex-wrap gap-12 gap-x-16">
|
||||
{isDefined(libraryItems) && (
|
||||
<SearchResultSection
|
||||
title={langui.library}
|
||||
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) => (
|
||||
<PreviewCard
|
||||
key={item.id}
|
||||
className="w-52"
|
||||
href={`/library/${item.slug}`}
|
||||
title={item._formatted.title}
|
||||
subtitle={item._formatted.subtitle}
|
||||
description={
|
||||
item._matchesPosition.descriptions &&
|
||||
item._matchesPosition.descriptions.length > 0
|
||||
? item._formatted.descriptions?.[0]
|
||||
: undefined
|
||||
}
|
||||
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}
|
||||
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) => (
|
||||
<PreviewCard
|
||||
className="w-56"
|
||||
key={item.id}
|
||||
href={`/contents/${item.slug}`}
|
||||
pre_title={item._formatted.translations?.[0]?.pre_title}
|
||||
title={item._formatted.translations?.[0]?.title}
|
||||
subtitle={item._formatted.translations?.[0]?.subtitle}
|
||||
description={item._formatted.translations?.[0]?.description}
|
||||
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(posts) && (
|
||||
<SearchResultSection
|
||||
title={langui.news}
|
||||
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}
|
||||
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;
|
||||
href: string;
|
||||
totalHits?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SearchResultSection = ({ title, href, totalHits, children }: SearchResultSectionProps) => (
|
||||
<>
|
||||
{isDefined(totalHits) && totalHits > 0 && (
|
||||
<div>
|
||||
<div className="mb-6 grid place-content-start">
|
||||
<UpPressable className="px-6 py-4" href={href}>
|
||||
<p className="font-headers text-lg">{title}</p>
|
||||
{isDefined(totalHits) && totalHits > SEARCH_LIMIT && (
|
||||
<p className="text-sm">{`(showing ${SEARCH_LIMIT} out of ${totalHits} results)`}</p>
|
||||
)}
|
||||
</UpPressable>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
@ -1,9 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Chip } from "./Chip";
|
||||
import { Ico, Icon } from "./Ico";
|
||||
import { Img } from "./Img";
|
||||
import { UpPressable } from "./Containers/UpPressable";
|
||||
import { Markdown } from "./Markdown/Markdown";
|
||||
import { Chip } from "components/Chip";
|
||||
import { Ico, Icon } from "components/Ico";
|
||||
import { Img } from "components/Img";
|
||||
import { UpPressable } from "components/Containers/UpPressable";
|
||||
import { DatePickerFragment, PricePickerFragment, UploadImageFragment } from "graphql/generated";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { prettyDate, prettyDuration, prettyPrice, prettyShortenNumber } from "helpers/formatters";
|
||||
@ -48,6 +49,7 @@ interface Props {
|
||||
}
|
||||
| { __typename: "anotherHoverlayName" };
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
|
||||
@ -68,6 +70,7 @@ export const PreviewCard = ({
|
||||
metadata,
|
||||
hoverlay,
|
||||
infoAppend,
|
||||
className,
|
||||
disabled = false,
|
||||
}: Props): JSX.Element => {
|
||||
const currency = useAtomGetter(atoms.settings.currency);
|
||||
@ -100,7 +103,7 @@ export const PreviewCard = ({
|
||||
{metadata.author && (
|
||||
<p className="text-sm">
|
||||
<Ico icon={Icon.Person} className="mr-1 translate-y-[.15em] !text-base" />
|
||||
{metadata.author}
|
||||
<Markdown text={metadata.author} className="inline-block" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -109,7 +112,11 @@ export const PreviewCard = ({
|
||||
);
|
||||
|
||||
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"))}>
|
||||
{thumbnail ? (
|
||||
<div
|
||||
@ -132,17 +139,16 @@ export const PreviewCard = ({
|
||||
{hoverlay && hoverlay.__typename === "Video" && (
|
||||
<>
|
||||
<div
|
||||
className="group absolute inset-0 grid place-content-center bg-shade bg-opacity-0
|
||||
text-light transition-colors
|
||||
hover:bg-opacity-50">
|
||||
className="absolute inset-0 grid place-content-center bg-shade bg-opacity-0
|
||||
text-light transition-colors group-hover:bg-opacity-50">
|
||||
<Ico
|
||||
icon={Icon.PlayCircleOutline}
|
||||
className="!text-6xl text-black opacity-0 drop-shadow-lg transition-opacity
|
||||
shadow-shade group-hover:opacity-100"
|
||||
shadow-shade group-hover:opacity-100"
|
||||
/>
|
||||
</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">
|
||||
{prettyDuration(hoverlay.duration)}
|
||||
</div>
|
||||
@ -174,24 +180,27 @@ export const PreviewCard = ({
|
||||
{topChips && topChips.length > 0 && (
|
||||
<div
|
||||
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
|
||||
scrollbar-none">
|
||||
scrollbar-none">
|
||||
{topChips.map((text, index) => (
|
||||
<Chip key={index} text={text} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<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 && (
|
||||
<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>
|
||||
{description && <p>{description}</p>}
|
||||
{description && <Markdown text={description} className="break-words" />}
|
||||
{bottomChips && bottomChips.length > 0 && (
|
||||
<div
|
||||
className="grid grid-flow-col place-content-start gap-1 overflow-x-scroll
|
||||
scrollbar-none">
|
||||
scrollbar-none">
|
||||
{bottomChips.map((text, index) => (
|
||||
<Chip key={index} className="text-sm" text={text} />
|
||||
))}
|
||||
|
@ -70,10 +70,10 @@ export const SmartList = <T,>({
|
||||
sortingFunction = defaultSortingFunction,
|
||||
className,
|
||||
}: Props<T>): JSX.Element => {
|
||||
const [page, setPage] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const langui = useAtomGetter(atoms.localData.langui);
|
||||
useScrollTopOnChange(Ids.ContentPanel, [page], paginationScroolTop);
|
||||
useEffect(() => setPage(0), [searchingTerm, groupingFunction, groupSortingFunction]);
|
||||
useEffect(() => setPage(1), [searchingTerm, groupingFunction, groupSortingFunction]);
|
||||
|
||||
const searchFilter = useCallback(() => {
|
||||
if (isDefinedAndNotEmpty(searchingTerm) && isDefined(searchingBy)) {
|
||||
@ -158,9 +158,9 @@ export const SmartList = <T,>({
|
||||
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), {
|
||||
enabled: page < pages.length - 1,
|
||||
enabled: page < pages.length,
|
||||
});
|
||||
|
||||
return (
|
||||
@ -170,8 +170,8 @@ export const SmartList = <T,>({
|
||||
)}
|
||||
|
||||
<div className="mb-8">
|
||||
{(pages[page]?.length ?? 0) > 0 ? (
|
||||
pages[page]?.map(
|
||||
{(pages[page - 1]?.length ?? 0) > 0 ? (
|
||||
pages[page - 1]?.map(
|
||||
(group) =>
|
||||
group.items.length > 0 && (
|
||||
<Fragment key={group.name}>
|
||||
|
@ -7,6 +7,7 @@ import { atoms } from "contexts/atoms";
|
||||
export const useAppLayout = (): void => {
|
||||
const router = useRouter();
|
||||
|
||||
const setSearchOpened = useAtomSetter(atoms.layout.searchOpened);
|
||||
const setSettingsOpened = useAtomSetter(atoms.layout.settingsOpened);
|
||||
const setMainPanelOpened = useAtomSetter(atoms.layout.mainPanelOpened);
|
||||
const setSubPanelOpened = useAtomSetter(atoms.layout.subPanelOpened);
|
||||
@ -14,6 +15,7 @@ export const useAppLayout = (): void => {
|
||||
useEffect(() => {
|
||||
router.events.on("routeChangeStart", () => {
|
||||
console.log("[Router Events] on routeChangeStart");
|
||||
setSearchOpened(false);
|
||||
setSettingsOpened(false);
|
||||
setMainPanelOpened(false);
|
||||
setSubPanelOpened(false);
|
||||
@ -23,7 +25,7 @@ export const useAppLayout = (): void => {
|
||||
console.log("[Router Events] on hashChangeStart");
|
||||
setSubPanelOpened(false);
|
||||
});
|
||||
}, [router, setSettingsOpened, setMainPanelOpened, setSubPanelOpened]);
|
||||
}, [router, setSettingsOpened, setMainPanelOpened, setSubPanelOpened, setSearchOpened]);
|
||||
|
||||
useScrollIntoView();
|
||||
};
|
||||
|
@ -14,13 +14,15 @@ import { lightBox } from "contexts/LightBoxProvider";
|
||||
/* [ APPLAYOUT ATOMS ] */
|
||||
|
||||
const mainPanelReduced = atomPairing(atomWithStorage("isMainPanelReduced", false));
|
||||
const settingsOpened = atomPairing(atomWithStorage("isSettingsOpened", false));
|
||||
const subPanelOpened = atomPairing(atomWithStorage("isSubPanelOpened", false));
|
||||
const mainPanelOpened = atomPairing(atomWithStorage("isMainPanelOpened", false));
|
||||
const searchOpened = atomPairing(atom(false));
|
||||
const settingsOpened = atomPairing(atom(false));
|
||||
const subPanelOpened = atomPairing(atom(false));
|
||||
const mainPanelOpened = atomPairing(atom(false));
|
||||
const menuGesturesEnabled = atomPairing(atomWithStorage("isMenuGesturesEnabled", false));
|
||||
const terminalMode = atom((get) => get(settings.playerName[0]) === "root");
|
||||
|
||||
const layout = {
|
||||
searchOpened,
|
||||
mainPanelReduced,
|
||||
settingsOpened,
|
||||
subPanelOpened,
|
||||
|
@ -35,33 +35,6 @@ query getContents($language_code: String) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
@ -5,29 +5,6 @@ query getVideoChannel($channel: String) {
|
||||
uid
|
||||
title
|
||||
subscribers
|
||||
videos(pagination: { limit: -1 }) {
|
||||
data {
|
||||
id
|
||||
attributes {
|
||||
uid
|
||||
title
|
||||
views
|
||||
duration
|
||||
gone
|
||||
categories(pagination: { limit: -1 }) {
|
||||
data {
|
||||
id
|
||||
attributes {
|
||||
short
|
||||
}
|
||||
}
|
||||
}
|
||||
published_date {
|
||||
...datePicker
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -191,6 +191,14 @@ query localDataGetWebsiteInterfaces {
|
||||
reset_all_options
|
||||
reading_layout
|
||||
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 => {
|
||||
const eventName = `[${category}] ${event}`;
|
||||
console.log(`Event: ${eventName}`);
|
||||
try {
|
||||
umami(`[${category}] ${event}`);
|
||||
umami(eventName);
|
||||
} catch (error) {
|
||||
if (error instanceof ReferenceError) return;
|
||||
console.log(error);
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { isUndefined } from "./asserts";
|
||||
import { DatePickerFragment } from "graphql/generated";
|
||||
import { isDefined, isUndefined } from "./asserts";
|
||||
|
||||
type DatePickerFragment = {
|
||||
year?: number | null;
|
||||
month?: number | null;
|
||||
day?: number | null;
|
||||
};
|
||||
|
||||
export const compareDate = (
|
||||
a: DatePickerFragment | null | undefined,
|
||||
@ -8,10 +13,11 @@ export const compareDate = (
|
||||
if (isUndefined(a) || isUndefined(b)) {
|
||||
return 0;
|
||||
}
|
||||
const dateA = (a.year ?? Infinity) * 365 + (a.month ?? 12) * 31 + (a.day ?? 31);
|
||||
const dateB = (b.year ?? Infinity) * 365 + (b.month ?? 12) * 31 + (b.day ?? 31);
|
||||
return dateA - dateB;
|
||||
return dateInDays(a) - dateInDays(b);
|
||||
};
|
||||
|
||||
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 =>
|
||||
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 */
|
||||
|
||||
export const prettyShortenNumber = (number: number): string => {
|
||||
if (number > 1000000) {
|
||||
return number.toLocaleString(undefined, {
|
||||
if (number > 1_000_000) {
|
||||
return `${(number / 1_000_000).toLocaleString(undefined, {
|
||||
maximumSignificantDigits: 3,
|
||||
});
|
||||
} else if (number > 1000) {
|
||||
return `${(number / 1000).toLocaleString(undefined, {
|
||||
})}M`;
|
||||
} else if (number > 1_000) {
|
||||
return `${(number / 1_000).toLocaleString(undefined, {
|
||||
maximumSignificantDigits: 2,
|
||||
})}K`;
|
||||
}
|
||||
|
48
src/helpers/search.ts
Normal file
48
src/helpers/search.ts
Normal file
@ -0,0 +1,48 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// eslint-disable-next-line import/named
|
||||
import { MatchesPosition, MeiliSearch, SearchParams, SearchResponse } from "meilisearch";
|
||||
import { isDefined } from "./asserts";
|
||||
import { MeiliDocumentsType } from "shared/meilisearch-graphql-typings/meiliTypes";
|
||||
|
||||
const meili = new MeiliSearch({
|
||||
host: process.env.NEXT_PUBLIC_URL_MEILISEARCH ?? "",
|
||||
apiKey: process.env.NEXT_PUBLIC_MEILISEARCH_KEY,
|
||||
});
|
||||
|
||||
interface CustomSearchParams
|
||||
extends Omit<
|
||||
SearchParams,
|
||||
"cropMarker" | "highlightPostTag" | "highlightPreTag" | "q" | "showMatchesPosition"
|
||||
> {}
|
||||
|
||||
type CustomHit<T = Record<string, any>> = T & {
|
||||
_formatted: Partial<T>;
|
||||
_matchesPosition: MatchesPosition<T>;
|
||||
};
|
||||
|
||||
type CustomHits<T = Record<string, any>> = CustomHit<T>[];
|
||||
|
||||
export interface CustomSearchResponse<T> extends Omit<SearchResponse<T>, "hits"> {
|
||||
hits: CustomHits<T>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const meiliSearch = async <I extends MeiliDocumentsType["index"]>(
|
||||
indexName: I,
|
||||
query: string,
|
||||
options: CustomSearchParams
|
||||
) => {
|
||||
const index = meili.index(indexName);
|
||||
return (await index.search<Extract<MeiliDocumentsType, { index: I }>["documents"]>(query, {
|
||||
...options,
|
||||
attributesToHighlight: options.attributesToHighlight ?? ["*"],
|
||||
highlightPreTag: "<mark>",
|
||||
highlightPostTag: "</mark>",
|
||||
showMatchesPosition: true,
|
||||
cropLength: 20,
|
||||
cropMarker: "...",
|
||||
})) as unknown as CustomSearchResponse<Extract<MeiliDocumentsType, { index: I }>["documents"]>;
|
||||
};
|
||||
|
||||
export const containsHighlight = (text: string | null | undefined): boolean =>
|
||||
isDefined(text) && text.includes("</mark>");
|
36
src/hooks/useTypedRouter.ts
Normal file
36
src/hooks/useTypedRouter.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { NextRouter, useRouter } from "next/router";
|
||||
import { useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
interface TypeRouter<T extends z.Schema> extends Omit<NextRouter, "query"> {
|
||||
query: z.TypeOf<T>;
|
||||
updateQuery: (queryParams: z.TypeOf<T>) => void;
|
||||
}
|
||||
|
||||
export const useTypedRouter = <T extends z.Schema>(schema: T): TypeRouter<T> => {
|
||||
const { query, ...router } = useRouter();
|
||||
|
||||
const updateQuery = useCallback(
|
||||
async (queryParams: z.TypeOf<T>) => {
|
||||
Object.keys(queryParams).map((key: keyof typeof queryParams) => {
|
||||
if (typeof queryParams[key] === "boolean") {
|
||||
queryParams[key] = queryParams[key] ? "true" : undefined;
|
||||
}
|
||||
});
|
||||
await router.replace(
|
||||
{ pathname: router.pathname, query: { ...query, ...queryParams } },
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
}
|
||||
);
|
||||
},
|
||||
[router, query]
|
||||
);
|
||||
|
||||
return {
|
||||
query: schema.parse(query) as z.infer<typeof schema>,
|
||||
updateQuery,
|
||||
...router,
|
||||
};
|
||||
};
|
@ -23,6 +23,7 @@ import { SettingsPopup } from "components/Panels/SettingsPopup";
|
||||
import { useSettings } from "contexts/settings";
|
||||
import { useContainerQueries } from "contexts/containerQueries";
|
||||
import { useWebkitFixes } from "contexts/webkitFixes";
|
||||
import { SearchPopup } from "components/Panels/SearchPopup";
|
||||
|
||||
const AccordsLibraryApp = (props: AppProps): JSX.Element => {
|
||||
useLocalData();
|
||||
@ -33,6 +34,7 @@ const AccordsLibraryApp = (props: AppProps): JSX.Element => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchPopup />
|
||||
<SettingsPopup />
|
||||
<LightBoxProvider />
|
||||
<Script
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { i18n } from "../../../next.config";
|
||||
import { cartesianProduct } from "helpers/others";
|
||||
import { filterHasAttributes, isDefined } from "helpers/asserts";
|
||||
import { filterHasAttributes } from "helpers/asserts";
|
||||
import { fetchLocalData } from "graphql/fetchLocalData";
|
||||
import { getReadySdk } from "graphql/sdk";
|
||||
|
||||
@ -169,13 +169,11 @@ const Revalidate = async (
|
||||
|
||||
switch (body.model) {
|
||||
case "post": {
|
||||
paths.push(`/news`);
|
||||
paths.push(`/news/${body.entry.slug}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "library-item": {
|
||||
paths.push(`/library`);
|
||||
paths.push(`/library/${body.entry.slug}`);
|
||||
paths.push(`/library/${body.entry.slug}/reader`);
|
||||
|
||||
@ -193,8 +191,6 @@ const Revalidate = async (
|
||||
}
|
||||
|
||||
case "content": {
|
||||
paths.push(`/contents`);
|
||||
paths.push(`/contents/all`);
|
||||
paths.push(`/contents/${body.entry.slug}`);
|
||||
|
||||
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;
|
||||
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, [
|
||||
@ -248,8 +248,9 @@ const Revalidate = async (
|
||||
case "contents-folder": {
|
||||
if (body.entry.slug === "root") {
|
||||
paths.push(`/contents`);
|
||||
} else {
|
||||
paths.push(`/contents/folder/${body.entry.slug}`);
|
||||
}
|
||||
paths.push(`/contents/folder/${body.entry.slug}`);
|
||||
|
||||
if (
|
||||
body.entry.contents.count > 0 ||
|
||||
@ -278,7 +279,6 @@ const Revalidate = async (
|
||||
}
|
||||
|
||||
case "wiki-page": {
|
||||
paths.push(`/wiki`);
|
||||
paths.push(`/wiki/${body.entry.slug}`);
|
||||
break;
|
||||
}
|
||||
@ -306,13 +306,7 @@ const Revalidate = async (
|
||||
|
||||
case "video": {
|
||||
if (body.entry.uid) {
|
||||
paths.push(`/archives/videos`);
|
||||
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;
|
||||
}
|
||||
|
@ -1,29 +1,34 @@
|
||||
import { GetStaticPaths, GetStaticPathsResult, GetStaticProps } from "next";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useBoolean } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
|
||||
import { Icon } from "components/Ico";
|
||||
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 { ReturnButton } from "components/PanelComponents/ReturnButton";
|
||||
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
|
||||
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 { WithLabel } from "components/Inputs/WithLabel";
|
||||
import { filterHasAttributes, isDefined } from "helpers/asserts";
|
||||
import { getOpenGraph } from "helpers/openGraph";
|
||||
import { compareDate } from "helpers/date";
|
||||
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 { atoms } from "contexts/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,29 +37,130 @@ import { useAtomGetter } from "helpers/atoms";
|
||||
|
||||
const DEFAULT_FILTERS_STATE = {
|
||||
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 ╰─────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
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 { value: keepInfoVisible, toggle: toggleKeepInfoVisible } = useBoolean(true);
|
||||
const langui = useAtomGetter(atoms.localData.langui);
|
||||
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",
|
||||
],
|
||||
attributesToCrop: ["description"],
|
||||
attributesToHighlight: ["*"],
|
||||
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) {
|
||||
console.log(router.query);
|
||||
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 = (
|
||||
<SubPanel>
|
||||
<ReturnButton
|
||||
href="/archives/videos/"
|
||||
href="/archives/videos"
|
||||
title={langui.videos}
|
||||
displayOnlyOn={"3ColumnsLayout"}
|
||||
className="mb-10"
|
||||
@ -62,65 +168,110 @@ const Channel = ({ channel, ...otherProps }: Props): JSX.Element => {
|
||||
|
||||
<PanelHeader
|
||||
icon={Icon.Movie}
|
||||
title={langui.videos}
|
||||
description={langui.archives_description}
|
||||
title={channel.title}
|
||||
description={`${channel.subscribers.toLocaleString()} ${langui.subscribers?.toLowerCase()}`}
|
||||
/>
|
||||
|
||||
<HorizontalLine />
|
||||
|
||||
<TextInput
|
||||
className="mb-6 w-full"
|
||||
placeholder={langui.search_title ?? "Search title..."}
|
||||
value={searchName}
|
||||
onChange={setSearchName}
|
||||
placeholder={langui.search_title}
|
||||
value={query}
|
||||
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 && (
|
||||
<WithLabel label={langui.always_show_info}>
|
||||
<Switch value={keepInfoVisible} onClick={toggleKeepInfoVisible} />
|
||||
</WithLabel>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="mt-8"
|
||||
text={langui.reset_all_filters}
|
||||
icon={Icon.Replay}
|
||||
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>
|
||||
);
|
||||
|
||||
const contentPanel = (
|
||||
<ContentPanel width={ContentPanelWidthSizes.Full}>
|
||||
<SmartList
|
||||
items={filterHasAttributes(channel?.videos?.data, ["id", "attributes"] as const)}
|
||||
getItemId={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<PreviewCard
|
||||
href={`/archives/videos/v/${item.attributes.uid}`}
|
||||
title={item.attributes.title}
|
||||
thumbnail={getVideoThumbnailURL(item.attributes.uid)}
|
||||
thumbnailAspectRatio="16/9"
|
||||
thumbnailForceAspectRatio
|
||||
keepInfoVisible={keepInfoVisible}
|
||||
metadata={{
|
||||
releaseDate: item.attributes.published_date,
|
||||
views: item.attributes.views,
|
||||
author: channel?.title,
|
||||
position: "Top",
|
||||
}}
|
||||
hoverlay={{
|
||||
__typename: "Video",
|
||||
duration: item.attributes.duration,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
className={cIf(
|
||||
isContentPanelAtLeast4xl,
|
||||
"grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] gap-x-6 gap-y-8",
|
||||
"grid-cols-2 gap-x-3 gap-y-5"
|
||||
)}
|
||||
groupingFunction={() => [channel?.title ?? ""]}
|
||||
paginationItemPerPage={25}
|
||||
searchingTerm={searchName}
|
||||
searchingBy={(item) => item.attributes.title}
|
||||
/>
|
||||
<Paginator page={page} onPageChange={setPage} totalNumberOfPages={videos?.totalPages}>
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
|
||||
gap-x-6 gap-y-8">
|
||||
{videos?.hits.map((item) => (
|
||||
<PreviewCard
|
||||
key={item.uid}
|
||||
href={`/archives/videos/v/${item.uid}`}
|
||||
title={item._formatted.title}
|
||||
thumbnail={getVideoThumbnailURL(item.uid)}
|
||||
thumbnailAspectRatio="16/9"
|
||||
thumbnailForceAspectRatio
|
||||
keepInfoVisible={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>
|
||||
</Paginator>
|
||||
</ContentPanel>
|
||||
);
|
||||
|
||||
return <AppLayout subPanel={subPanel} contentPanel={contentPanel} {...otherProps} />;
|
||||
};
|
||||
export default Channel;
|
||||
@ -138,10 +289,6 @@ export const getStaticProps: GetStaticProps = async (context) => {
|
||||
});
|
||||
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 = {
|
||||
channel: channel.videoChannels.data[0].attributes,
|
||||
openGraph: getOpenGraph(langui, channel.videoChannels.data[0].attributes.title),
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { GetStaticProps } from "next";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useBoolean } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
|
||||
import { SmartList } from "components/SmartList";
|
||||
import { Icon } from "components/Ico";
|
||||
import { Switch } from "components/Inputs/Switch";
|
||||
import { TextInput } from "components/Inputs/TextInput";
|
||||
@ -11,19 +11,22 @@ import { PanelHeader } from "components/PanelComponents/PanelHeader";
|
||||
import { ReturnButton } from "components/PanelComponents/ReturnButton";
|
||||
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
|
||||
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 { getOpenGraph } from "helpers/openGraph";
|
||||
import { compareDate } from "helpers/date";
|
||||
import { HorizontalLine } from "components/HorizontalLine";
|
||||
import { cIf } from "helpers/className";
|
||||
import { getLangui } from "graphql/fetchLocalData";
|
||||
import { atoms } from "contexts/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 +35,121 @@ import { useAtomGetter } from "helpers/atoms";
|
||||
|
||||
const DEFAULT_FILTERS_STATE = {
|
||||
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 ╰─────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props extends AppLayoutRequired {
|
||||
videos: NonNullable<GetVideosPreviewQuery["videos"]>["data"];
|
||||
}
|
||||
interface Props extends AppLayoutRequired {}
|
||||
|
||||
const Videos = ({ videos, ...otherProps }: Props): JSX.Element => {
|
||||
const Videos = ({ ...otherProps }: Props): JSX.Element => {
|
||||
const langui = useAtomGetter(atoms.localData.langui);
|
||||
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",
|
||||
],
|
||||
attributesToCrop: ["description"],
|
||||
attributesToHighlight: ["*"],
|
||||
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) {
|
||||
console.log(router.query);
|
||||
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 = (
|
||||
<SubPanel>
|
||||
@ -61,59 +160,111 @@ const Videos = ({ videos, ...otherProps }: Props): JSX.Element => {
|
||||
className="mb-10"
|
||||
/>
|
||||
|
||||
<PanelHeader icon={Icon.Movie} title="Videos" description={langui.archives_description} />
|
||||
<PanelHeader
|
||||
icon={Icon.Movie}
|
||||
title={langui.videos}
|
||||
description={langui.archives_description}
|
||||
/>
|
||||
|
||||
<HorizontalLine />
|
||||
|
||||
<TextInput
|
||||
className="mb-6 w-full"
|
||||
placeholder={langui.search_title ?? "Search title..."}
|
||||
value={searchName}
|
||||
onChange={setSearchName}
|
||||
placeholder={langui.search_title}
|
||||
value={query}
|
||||
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 && (
|
||||
<WithLabel label={langui.always_show_info}>
|
||||
<Switch value={keepInfoVisible} onClick={toggleKeepInfoVisible} />
|
||||
</WithLabel>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="mt-8"
|
||||
text={langui.reset_all_filters}
|
||||
icon={Icon.Replay}
|
||||
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>
|
||||
);
|
||||
|
||||
const contentPanel = (
|
||||
<ContentPanel width={ContentPanelWidthSizes.Full}>
|
||||
<SmartList
|
||||
items={filterHasAttributes(videos, ["id", "attributes"] as const)}
|
||||
getItemId={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<PreviewCard
|
||||
href={`/archives/videos/v/${item.attributes.uid}`}
|
||||
title={item.attributes.title}
|
||||
thumbnail={getVideoThumbnailURL(item.attributes.uid)}
|
||||
thumbnailAspectRatio="16/9"
|
||||
thumbnailForceAspectRatio
|
||||
keepInfoVisible={keepInfoVisible}
|
||||
metadata={{
|
||||
releaseDate: item.attributes.published_date,
|
||||
views: item.attributes.views,
|
||||
author: item.attributes.channel?.data?.attributes?.title,
|
||||
position: "Top",
|
||||
}}
|
||||
hoverlay={{
|
||||
__typename: "Video",
|
||||
duration: item.attributes.duration,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
className={cIf(
|
||||
isContentPanelAtLeast4xl,
|
||||
"grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] gap-x-6 gap-y-8",
|
||||
"grid-cols-2 gap-x-3 gap-y-5"
|
||||
)}
|
||||
paginationItemPerPage={25}
|
||||
searchingTerm={searchName}
|
||||
searchingBy={(item) => item.attributes.title}
|
||||
/>
|
||||
<Paginator page={page} onPageChange={setPage} totalNumberOfPages={videos?.totalPages}>
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
|
||||
gap-x-6 gap-y-8">
|
||||
{videos?.hits.map((item) => (
|
||||
<PreviewCard
|
||||
key={item.uid}
|
||||
href={`/archives/videos/v/${item.uid}`}
|
||||
title={item._formatted.title}
|
||||
thumbnail={getVideoThumbnailURL(item.uid)}
|
||||
thumbnailAspectRatio="16/9"
|
||||
thumbnailForceAspectRatio
|
||||
keepInfoVisible={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>
|
||||
</Paginator>
|
||||
</ContentPanel>
|
||||
);
|
||||
return <AppLayout subPanel={subPanel} contentPanel={contentPanel} {...otherProps} />;
|
||||
@ -125,17 +276,9 @@ export default Videos;
|
||||
* ───────────────────────────────────╯ NEXT DATA FETCHING ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (context) => {
|
||||
const sdk = getReadySdk();
|
||||
export const getStaticProps: GetStaticProps = (context) => {
|
||||
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 = {
|
||||
videos: videos.videos.data,
|
||||
openGraph: getOpenGraph(langui, langui.videos ?? "Videos"),
|
||||
};
|
||||
return {
|
||||
|
@ -109,7 +109,7 @@ const Video = ({ video, ...otherProps }: Props): JSX.Element => {
|
||||
<h2 className="text-2xl">{langui.channel}</h2>
|
||||
<div>
|
||||
<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}
|
||||
/>
|
||||
<p>
|
||||
|
@ -1,15 +1,13 @@
|
||||
import { GetStaticProps } from "next";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useBoolean } from "usehooks-ts";
|
||||
import naturalCompare from "string-natural-compare";
|
||||
import { z } from "zod";
|
||||
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
|
||||
import { Select } from "components/Inputs/Select";
|
||||
import { Switch } from "components/Inputs/Switch";
|
||||
import { PanelHeader } from "components/PanelComponents/PanelHeader";
|
||||
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
|
||||
import { SubPanel } from "components/Containers/SubPanel";
|
||||
import { getReadySdk } from "graphql/sdk";
|
||||
import { prettyInlineTitle, prettySlug } from "helpers/formatters";
|
||||
import { TextInput } from "components/Inputs/TextInput";
|
||||
import { WithLabel } from "components/Inputs/WithLabel";
|
||||
import { Button } from "components/Inputs/Button";
|
||||
@ -18,19 +16,21 @@ import { Icon } from "components/Ico";
|
||||
import {
|
||||
filterDefined,
|
||||
filterHasAttributes,
|
||||
isDefined,
|
||||
isDefinedAndNotEmpty,
|
||||
SelectiveNonNullable,
|
||||
} from "helpers/asserts";
|
||||
import { GetContentsQuery } from "graphql/generated";
|
||||
import { SmartList } from "components/SmartList";
|
||||
import { getOpenGraph } from "helpers/openGraph";
|
||||
import { HorizontalLine } from "components/HorizontalLine";
|
||||
import { TranslatedPreviewCard } from "components/PreviewCard";
|
||||
import { cJoin, cIf } from "helpers/className";
|
||||
import { getLangui } from "graphql/fetchLocalData";
|
||||
import { sendAnalytics } from "helpers/analytics";
|
||||
import { atoms } from "contexts/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,87 +38,96 @@ import { useAtomGetter } from "helpers/atoms";
|
||||
*/
|
||||
|
||||
const DEFAULT_FILTERS_STATE = {
|
||||
groupingMethod: -1,
|
||||
keepInfoVisible: false,
|
||||
searchName: "",
|
||||
sortingMethod: 0,
|
||||
keepInfoVisible: true,
|
||||
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 ╰─────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props extends AppLayoutRequired {
|
||||
contents: NonNullable<GetContentsQuery["contents"]>["data"];
|
||||
}
|
||||
interface Props extends AppLayoutRequired {}
|
||||
|
||||
const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
|
||||
const Contents = (props: Props): JSX.Element => {
|
||||
const hoverable = useDeviceSupportsHover();
|
||||
const langui = useAtomGetter(atoms.localData.langui);
|
||||
const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl);
|
||||
const router = useTypedRouter(queryParamSchema);
|
||||
|
||||
const [groupingMethod, setGroupingMethod] = useState<number>(
|
||||
DEFAULT_FILTERS_STATE.groupingMethod
|
||||
const sortingMethods = useMemo(
|
||||
() => [
|
||||
{ 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 {
|
||||
value: keepInfoVisible,
|
||||
toggle: toggleKeepInfoVisible,
|
||||
setValue: setKeepInfoVisible,
|
||||
} = 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(
|
||||
(
|
||||
item: SelectiveNonNullable<
|
||||
NonNullable<GetContentsQuery["contents"]>["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"];
|
||||
}
|
||||
case 1: {
|
||||
return [
|
||||
item.attributes.type?.data?.attributes?.titles?.[0]?.title ??
|
||||
item.attributes.type?.data?.attributes?.slug
|
||||
? prettySlug(item.attributes.type.data.attributes.slug)
|
||||
: langui.no_type ?? "No type",
|
||||
];
|
||||
}
|
||||
default: {
|
||||
return [""];
|
||||
}
|
||||
}
|
||||
},
|
||||
[groupingMethod, langui]
|
||||
);
|
||||
|
||||
const filteringFunction = useCallback(
|
||||
(item: SelectiveNonNullable<Props["contents"][number], "attributes" | "id">) => {
|
||||
if (searchName.length > 1) {
|
||||
useEffect(() => {
|
||||
const fetchPosts = async () => {
|
||||
const currentSortingMethod = sortingMethods[sortingMethod];
|
||||
const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, {
|
||||
attributesToCrop: ["translations.description"],
|
||||
hitsPerPage: 25,
|
||||
page,
|
||||
sort: isDefined(currentSortingMethod) ? [currentSortingMethod.meiliAttribute] : undefined,
|
||||
});
|
||||
searchResult.hits = searchResult.hits.map((item) => {
|
||||
if (
|
||||
filterDefined(item.attributes.translations).find((translation) =>
|
||||
prettyInlineTitle(translation.pre_title, translation.title, translation.subtitle)
|
||||
.toLowerCase()
|
||||
.includes(searchName.toLowerCase())
|
||||
)
|
||||
Object.keys(item._matchesPosition).filter((match) => match.startsWith("translations"))
|
||||
.length > 0
|
||||
) {
|
||||
return true;
|
||||
item._formatted.translations = filterDefined(item._formatted.translations).filter(
|
||||
(translation) => containsHighlight(JSON.stringify(translation))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[searchName]
|
||||
);
|
||||
return item;
|
||||
});
|
||||
setContents(searchResult);
|
||||
};
|
||||
fetchPosts();
|
||||
}, [query, page, sortingMethod, sortingMethods]);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady)
|
||||
router.updateQuery({
|
||||
page,
|
||||
query,
|
||||
sort: sortingMethod,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, query, sortingMethod, 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);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.isReady]);
|
||||
|
||||
const subPanel = (
|
||||
<SubPanel>
|
||||
@ -137,9 +146,10 @@ const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
|
||||
<TextInput
|
||||
className="mb-6 w-full"
|
||||
placeholder={langui.search_title ?? "Search..."}
|
||||
value={searchName}
|
||||
value={query}
|
||||
onChange={(name) => {
|
||||
setSearchName(name);
|
||||
setPage(1);
|
||||
setQuery(name);
|
||||
if (isDefinedAndNotEmpty(name)) {
|
||||
sendAnalytics("Contents/All", "Change search term");
|
||||
} else {
|
||||
@ -148,19 +158,19 @@ const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<WithLabel label={langui.group_by}>
|
||||
<WithLabel label={langui.order_by}>
|
||||
<Select
|
||||
className="w-full"
|
||||
options={[langui.category ?? "Category", langui.type ?? "Type"]}
|
||||
value={groupingMethod}
|
||||
onChange={(value) => {
|
||||
setGroupingMethod(value);
|
||||
options={sortingMethods.map((item) => item.displayedName ?? "")}
|
||||
value={sortingMethod}
|
||||
onChange={(newSort) => {
|
||||
setPage(1);
|
||||
setSortingMethod(newSort);
|
||||
sendAnalytics(
|
||||
"Contents/All",
|
||||
`Change grouping method (${["none", "category", "type"][value + 1]})`
|
||||
`Change sorting method (${sortingMethods.map((item) => item.displayedName)[newSort]})`
|
||||
);
|
||||
}}
|
||||
allowEmpty
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
@ -181,8 +191,9 @@ const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
|
||||
text={langui.reset_all_filters}
|
||||
icon={Icon.Replay}
|
||||
onClick={() => {
|
||||
setSearchName(DEFAULT_FILTERS_STATE.searchName);
|
||||
setGroupingMethod(DEFAULT_FILTERS_STATE.groupingMethod);
|
||||
setPage(1);
|
||||
setQuery(DEFAULT_FILTERS_STATE.query);
|
||||
setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod);
|
||||
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
|
||||
sendAnalytics("Contents/All", "Reset all filters");
|
||||
}}
|
||||
@ -192,61 +203,42 @@ const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
|
||||
|
||||
const contentPanel = (
|
||||
<ContentPanel width={ContentPanelWidthSizes.Full}>
|
||||
<SmartList
|
||||
items={filterHasAttributes(contents, ["attributes", "id"] as const)}
|
||||
getItemId={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TranslatedPreviewCard
|
||||
href={`/contents/${item.attributes.slug}`}
|
||||
translations={filterHasAttributes(item.attributes.translations, [
|
||||
"language.data.attributes.code",
|
||||
] as const).map((translation) => ({
|
||||
pre_title: translation.pre_title,
|
||||
title: translation.title,
|
||||
subtitle: translation.subtitle,
|
||||
language: translation.language.data.attributes.code,
|
||||
}))}
|
||||
fallback={{ title: prettySlug(item.attributes.slug) }}
|
||||
thumbnail={item.attributes.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio="3/2"
|
||||
thumbnailForceAspectRatio
|
||||
topChips={
|
||||
item.attributes.type?.data?.attributes
|
||||
? [
|
||||
item.attributes.type.data.attributes.titles?.[0]
|
||||
? item.attributes.type.data.attributes.titles[0]?.title
|
||||
: prettySlug(item.attributes.type.data.attributes.slug),
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
bottomChips={item.attributes.categories?.data.map(
|
||||
(category) => category.attributes?.short ?? ""
|
||||
)}
|
||||
keepInfoVisible={keepInfoVisible}
|
||||
/>
|
||||
)}
|
||||
className={cJoin(
|
||||
"items-end",
|
||||
cIf(
|
||||
isContentPanelAtLeast4xl,
|
||||
"grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] gap-x-6 gap-y-8",
|
||||
"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}
|
||||
/>
|
||||
<Paginator page={page} onPageChange={setPage} totalNumberOfPages={contents?.totalPages}>
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
|
||||
gap-x-6 gap-y-8">
|
||||
{contents?.hits.map((item) => (
|
||||
<TranslatedPreviewCard
|
||||
key={item.id}
|
||||
href={`/contents/${item.slug}`}
|
||||
translations={filterHasAttributes(item._formatted.translations, [
|
||||
"language.data.attributes.code",
|
||||
] as const).map(({ description, language, ...otherAttributes }) => ({
|
||||
...otherAttributes,
|
||||
description: containsHighlight(description) ? 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={keepInfoVisible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Paginator>
|
||||
</ContentPanel>
|
||||
);
|
||||
|
||||
@ -255,7 +247,7 @@ const Contents = ({ contents, ...otherProps }: Props): JSX.Element => {
|
||||
subPanel={subPanel}
|
||||
contentPanel={contentPanel}
|
||||
subPanelIcon={Icon.Search}
|
||||
{...otherProps}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -266,22 +258,10 @@ export default Contents;
|
||||
* ───────────────────────────────────╯ NEXT DATA FETCHING ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (context) => {
|
||||
const sdk = getReadySdk();
|
||||
export const getStaticProps: GetStaticProps = (context) => {
|
||||
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 = {
|
||||
contents: contents.contents.data,
|
||||
openGraph: getOpenGraph(langui, langui.contents ?? "Contents"),
|
||||
};
|
||||
return {
|
||||
|
@ -688,9 +688,8 @@ interface PageFiltersProps {
|
||||
}
|
||||
|
||||
const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
|
||||
const isDarkMode = useAtomGetter(atoms.settings.darkMode);
|
||||
const commonCss = cJoin(
|
||||
"absolute inset-0",
|
||||
"absolute inset-0 dark:opacity-100",
|
||||
cIf(page === "right", "[background-position-x:-100%]")
|
||||
);
|
||||
|
||||
@ -700,9 +699,9 @@ const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
|
||||
<div
|
||||
className={cJoin(
|
||||
commonCss,
|
||||
`bg-blend-multiply mix-blend-exclusion [background-image:url(/reader/paper.webp)]
|
||||
[background-size:20vmin_20vmin]`,
|
||||
cIf(bookType === "book", "bg-[#000]/60")
|
||||
`mix-blend-exclusion [background-image:url(/reader/paper.webp)]
|
||||
[background-size:20vmin_20vmin]`,
|
||||
cIf(bookType === "book", "opacity-60 dark:opacity-60")
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@ -711,9 +710,8 @@ const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
|
||||
<div
|
||||
className={cJoin(
|
||||
commonCss,
|
||||
`bg-blend-lighten mix-blend-multiply [background-image:url(/reader/book-fold.webp)]
|
||||
[background-size:200%_100%]`,
|
||||
cIf(!isDarkMode, "bg-[#FFF]/50")
|
||||
`opacity-50 mix-blend-multiply
|
||||
[background-image:url(/reader/book-fold.webp)] [background-size:200%_100%]`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
@ -723,8 +721,7 @@ const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
|
||||
<div
|
||||
className={cJoin(
|
||||
commonCss,
|
||||
`bg-blend-lighten mix-blend-multiply [background-size:200%_100%]`,
|
||||
cIf(!isDarkMode, "bg-[#FFF]/50"),
|
||||
"opacity-50 mix-blend-multiply [background-size:200%_100%]",
|
||||
cIf(
|
||||
page === "single",
|
||||
"[background-image:url(/reader/lighting-single-page.webp)]",
|
||||
@ -735,8 +732,8 @@ const PageFilters = ({ page, bookType, options }: PageFiltersProps) => {
|
||||
<div
|
||||
className={cJoin(
|
||||
commonCss,
|
||||
`bg-blend-lighten mix-blend-soft-light [background-size:200%_100%]`,
|
||||
cIf(!isDarkMode, "bg-[#FFF]/30"),
|
||||
`bg-[#FFF]/30 bg-blend-lighten mix-blend-soft-light [background-size:200%_100%]
|
||||
dark:bg-[#000]`,
|
||||
cIf(
|
||||
page === "single",
|
||||
"[background-image:url(/reader/specular-single-page.webp)]",
|
||||
|
@ -1,44 +1,36 @@
|
||||
import { GetStaticProps } from "next";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useBoolean } from "usehooks-ts";
|
||||
import naturalCompare from "string-natural-compare";
|
||||
import { z } from "zod";
|
||||
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
|
||||
import { Select } from "components/Inputs/Select";
|
||||
import { Switch } from "components/Inputs/Switch";
|
||||
import { PanelHeader } from "components/PanelComponents/PanelHeader";
|
||||
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
|
||||
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 { Icon } from "components/Ico";
|
||||
import { WithLabel } from "components/Inputs/WithLabel";
|
||||
import { TextInput } from "components/Inputs/TextInput";
|
||||
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 { ButtonGroup } from "components/Inputs/ButtonGroup";
|
||||
import {
|
||||
filterHasAttributes,
|
||||
isDefined,
|
||||
isDefinedAndNotEmpty,
|
||||
isUndefined,
|
||||
SelectiveNonNullable,
|
||||
} from "helpers/asserts";
|
||||
import { convertPrice } from "helpers/numbers";
|
||||
import { SmartList } from "components/SmartList";
|
||||
import { filterDefined, isDefined, isDefinedAndNotEmpty, isUndefined } from "helpers/asserts";
|
||||
import { getOpenGraph } from "helpers/openGraph";
|
||||
import { compareDate } from "helpers/date";
|
||||
import { HorizontalLine } from "components/HorizontalLine";
|
||||
import { cIf, cJoin } from "helpers/className";
|
||||
import { getLangui } from "graphql/fetchLocalData";
|
||||
import { sendAnalytics } from "helpers/analytics";
|
||||
import { atoms } from "contexts/atoms";
|
||||
import { useAtomGetter } from "helpers/atoms";
|
||||
import { CustomSearchResponse, meiliSearch } from "helpers/search";
|
||||
import { MeiliIndices, MeiliLibraryItem } from "shared/meilisearch-graphql-typings/meiliTypes";
|
||||
import { useTypedRouter } from "hooks/useTypedRouter";
|
||||
import { PreviewCard } 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 { Paginator } from "components/Containers/Paginator";
|
||||
|
||||
/*
|
||||
* ╭─────────────╮
|
||||
@ -46,52 +38,69 @@ import { useLibraryItemUserStatus } from "hooks/useLibraryItemUserStatus";
|
||||
*/
|
||||
|
||||
const DEFAULT_FILTERS_STATE = {
|
||||
searchName: "",
|
||||
query: "",
|
||||
showSubitems: false,
|
||||
showPrimaryItems: true,
|
||||
showSecondaryItems: false,
|
||||
page: 1,
|
||||
sortingMethod: 0,
|
||||
groupingMethod: -1,
|
||||
keepInfoVisible: false,
|
||||
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 ╰─────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props extends AppLayoutRequired {
|
||||
items: NonNullable<GetLibraryItemsPreviewQuery["libraryItems"]>["data"];
|
||||
}
|
||||
interface Props extends AppLayoutRequired {}
|
||||
|
||||
const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
const Library = (props: Props): JSX.Element => {
|
||||
const hoverable = useDeviceSupportsHover();
|
||||
const langui = useAtomGetter(atoms.localData.langui);
|
||||
const currencies = useAtomGetter(atoms.localData.currencies);
|
||||
|
||||
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 {
|
||||
value: showSubitems,
|
||||
toggle: toggleShowSubitems,
|
||||
setValue: setShowSubitems,
|
||||
} = useBoolean(DEFAULT_FILTERS_STATE.showSubitems);
|
||||
} = useBoolean(router.query.subitems ?? DEFAULT_FILTERS_STATE.showSubitems);
|
||||
|
||||
const {
|
||||
value: showPrimaryItems,
|
||||
toggle: toggleShowPrimaryItems,
|
||||
setValue: setShowPrimaryItems,
|
||||
} = useBoolean(DEFAULT_FILTERS_STATE.showPrimaryItems);
|
||||
} = useBoolean(router.query.primary ?? DEFAULT_FILTERS_STATE.showPrimaryItems);
|
||||
|
||||
const {
|
||||
value: showSecondaryItems,
|
||||
toggle: toggleShowSecondaryItems,
|
||||
setValue: setShowSecondaryItems,
|
||||
} = useBoolean(DEFAULT_FILTERS_STATE.showSecondaryItems);
|
||||
} = useBoolean(router.query.secondary ?? DEFAULT_FILTERS_STATE.showSecondaryItems);
|
||||
|
||||
const {
|
||||
value: keepInfoVisible,
|
||||
@ -99,136 +108,128 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
setValue: setKeepInfoVisible,
|
||||
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
|
||||
|
||||
const [sortingMethod, setSortingMethod] = useState<number>(DEFAULT_FILTERS_STATE.sortingMethod);
|
||||
|
||||
const [groupingMethod, setGroupingMethod] = useState<number>(
|
||||
DEFAULT_FILTERS_STATE.groupingMethod
|
||||
const [sortingMethod, setSortingMethod] = useState<number>(
|
||||
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
|
||||
);
|
||||
|
||||
const [filterUserStatus, setFilterUserStatus] = useState<LibraryItemUserStatus | undefined>(
|
||||
DEFAULT_FILTERS_STATE.filterUserStatus
|
||||
fromStringToLibraryItemUserStatus(router.query.status) ?? DEFAULT_FILTERS_STATE.filterUserStatus
|
||||
);
|
||||
|
||||
const filteringFunction = useCallback(
|
||||
(item: SelectiveNonNullable<Props["items"][number], "attributes" | "id">) => {
|
||||
if (!showSubitems && !item.attributes.root_item) return false;
|
||||
if (showSubitems && isUntangibleGroupItem(item.attributes.metadata?.[0])) {
|
||||
return false;
|
||||
}
|
||||
if (item.attributes.primary && !showPrimaryItems) return false;
|
||||
if (!item.attributes.primary && !showSecondaryItems) return false;
|
||||
useEffect(() => {
|
||||
const fetchLibraryItems = async () => {
|
||||
const currentSortingMethod = sortingMethods[sortingMethod];
|
||||
const filter: string[] = [];
|
||||
|
||||
if (isDefined(filterUserStatus) && item.id) {
|
||||
if (isUntangibleGroupItem(item.attributes.metadata?.[0])) {
|
||||
return false;
|
||||
}
|
||||
if (!showPrimaryItems && !showSecondaryItems) {
|
||||
filter.push("primary NOT EXISTS");
|
||||
} 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 (libraryItemUserStatus[item.id]) {
|
||||
return false;
|
||||
}
|
||||
} else if (filterUserStatus !== libraryItemUserStatus[item.id]) {
|
||||
return false;
|
||||
filter.push(
|
||||
`id NOT IN [${Object.entries(libraryItemUserStatus)
|
||||
.filter(([, value]) => value !== filterUserStatus)
|
||||
.map(([id]) => id)
|
||||
.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(
|
||||
(
|
||||
a: SelectiveNonNullable<Props["items"][number], "attributes" | "id">,
|
||||
b: SelectiveNonNullable<Props["items"][number], "attributes" | "id">
|
||||
) => {
|
||||
switch (sortingMethod) {
|
||||
case 0: {
|
||||
const titleA = prettyInlineTitle("", a.attributes.title, a.attributes.subtitle);
|
||||
const titleB = prettyInlineTitle("", b.attributes.title, b.attributes.subtitle);
|
||||
return naturalCompare(titleA, titleB);
|
||||
const searchResult = await meiliSearch(MeiliIndices.LIBRARY_ITEM, query, {
|
||||
hitsPerPage: 25,
|
||||
page,
|
||||
attributesToCrop: ["descriptions"],
|
||||
sort: isDefined(currentSortingMethod) ? [currentSortingMethod.meiliAttribute] : undefined,
|
||||
filter,
|
||||
});
|
||||
searchResult.hits = searchResult.hits.map((item) => {
|
||||
if (
|
||||
isDefined(item._formatted) &&
|
||||
item._matchesPosition.descriptions &&
|
||||
item._matchesPosition.descriptions.length > 0
|
||||
) {
|
||||
item._formatted.descriptions = filterDefined(item._formatted.descriptions).filter(
|
||||
(description) => description.includes("</mark>")
|
||||
);
|
||||
}
|
||||
case 1: {
|
||||
const commonCurrency = currencies[0];
|
||||
if (isUndefined(commonCurrency)) return 0;
|
||||
return item;
|
||||
});
|
||||
setLibraryItems(searchResult);
|
||||
};
|
||||
fetchLibraryItems();
|
||||
}, [
|
||||
filterUserStatus,
|
||||
libraryItemUserStatus,
|
||||
page,
|
||||
query,
|
||||
showPrimaryItems,
|
||||
showSecondaryItems,
|
||||
showSubitems,
|
||||
sortingMethod,
|
||||
sortingMethods,
|
||||
]);
|
||||
|
||||
const priceA = a.attributes.price
|
||||
? convertPrice(a.attributes.price, commonCurrency)
|
||||
: Infinity;
|
||||
const priceB = b.attributes.price
|
||||
? convertPrice(b.attributes.price, commonCurrency)
|
||||
: Infinity;
|
||||
return priceA - priceB;
|
||||
}
|
||||
case 2: {
|
||||
return compareDate(a.attributes.release_date, b.attributes.release_date);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
[currencies, sortingMethod]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (router.isReady) console.log(router.query, filterUserStatus);
|
||||
router.updateQuery({
|
||||
page,
|
||||
query,
|
||||
sort: sortingMethod,
|
||||
primary: showPrimaryItems,
|
||||
secondary: showSecondaryItems,
|
||||
subitems: showSubitems,
|
||||
status: fromLibraryItemUserStatusToString(filterUserStatus),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
page,
|
||||
query,
|
||||
sortingMethod,
|
||||
router.isReady,
|
||||
showPrimaryItems,
|
||||
showSecondaryItems,
|
||||
showSubitems,
|
||||
filterUserStatus,
|
||||
]);
|
||||
|
||||
const groupingFunction = useCallback(
|
||||
(item: SelectiveNonNullable<Props["items"][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"];
|
||||
}
|
||||
case 1: {
|
||||
if (item.attributes.metadata && item.attributes.metadata.length > 0) {
|
||||
switch (item.attributes.metadata[0]?.__typename) {
|
||||
case "ComponentMetadataAudio":
|
||||
return [langui.audio ?? "Audio"];
|
||||
case "ComponentMetadataGame":
|
||||
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]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (router.isReady) {
|
||||
console.log(router.query);
|
||||
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.primary)) setShowPrimaryItems(router.query.primary);
|
||||
if (isDefined(router.query.secondary)) setShowSecondaryItems(router.query.secondary);
|
||||
if (isDefined(router.query.subitems)) setShowSubitems(router.query.subitems);
|
||||
if (isDefined(router.query.status))
|
||||
setFilterUserStatus(fromStringToLibraryItemUserStatus(router.query.status));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const totalPages = libraryItems?.totalPages;
|
||||
if (isDefined(totalPages) && totalPages < page && totalPages >= 1) setPage(totalPages);
|
||||
}, [libraryItems?.totalPages, page]);
|
||||
|
||||
const subPanel = (
|
||||
<SubPanel>
|
||||
@ -243,9 +244,10 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
<TextInput
|
||||
className="mb-6 w-full"
|
||||
placeholder={langui.search_title ?? "Search..."}
|
||||
value={searchName}
|
||||
value={query}
|
||||
onChange={(name) => {
|
||||
setSearchName(name);
|
||||
setPage(1);
|
||||
setQuery(name);
|
||||
if (isDefinedAndNotEmpty(name)) {
|
||||
sendAnalytics("Library", "Change search term");
|
||||
} else {
|
||||
@ -254,40 +256,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}>
|
||||
<Select
|
||||
className="w-full"
|
||||
options={[
|
||||
langui.name ?? "Name",
|
||||
langui.price ?? "Price",
|
||||
langui.release_date ?? "Release date",
|
||||
]}
|
||||
options={sortingMethods.map((item) => item.displayedName ?? "")}
|
||||
value={sortingMethod}
|
||||
onChange={(value) => {
|
||||
setSortingMethod(value);
|
||||
onChange={(newSort) => {
|
||||
setPage(1);
|
||||
setSortingMethod(newSort);
|
||||
sendAnalytics(
|
||||
"Library",
|
||||
`Change sorting method (${["name", "price", "release date"][value]})`
|
||||
`Change sorting method (${sortingMethods.map((item) => item.displayedName)[newSort]})`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@ -297,6 +276,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
<Switch
|
||||
value={showSubitems}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
toggleShowSubitems();
|
||||
sendAnalytics("Library", `${showSubitems ? "Hide" : "Show"} subitems`);
|
||||
}}
|
||||
@ -307,6 +287,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
<Switch
|
||||
value={showPrimaryItems}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
toggleShowPrimaryItems();
|
||||
sendAnalytics("Library", `${showPrimaryItems ? "Hide" : "Show"} primary items`);
|
||||
}}
|
||||
@ -317,6 +298,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
<Switch
|
||||
value={showSecondaryItems}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
toggleShowSecondaryItems();
|
||||
sendAnalytics("Library", `${showSecondaryItems ? "Hide" : "Show"} secondary items`);
|
||||
}}
|
||||
@ -342,6 +324,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
tooltip: langui.only_display_items_i_want,
|
||||
icon: Icon.Favorite,
|
||||
onClick: () => {
|
||||
setPage(1);
|
||||
setFilterUserStatus(LibraryItemUserStatus.Want);
|
||||
sendAnalytics("Library", "Set filter status (I want)");
|
||||
},
|
||||
@ -351,6 +334,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
tooltip: langui.only_display_items_i_have,
|
||||
icon: Icon.BackHand,
|
||||
onClick: () => {
|
||||
setPage(1);
|
||||
setFilterUserStatus(LibraryItemUserStatus.Have);
|
||||
sendAnalytics("Library", "Set filter status (I have)");
|
||||
},
|
||||
@ -360,6 +344,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
tooltip: langui.only_display_unmarked_items,
|
||||
icon: Icon.RadioButtonUnchecked,
|
||||
onClick: () => {
|
||||
setPage(1);
|
||||
setFilterUserStatus(LibraryItemUserStatus.None);
|
||||
sendAnalytics("Library", "Set filter status (unmarked)");
|
||||
},
|
||||
@ -369,6 +354,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
tooltip: langui.only_display_unmarked_items,
|
||||
text: langui.all,
|
||||
onClick: () => {
|
||||
setPage(1);
|
||||
setFilterUserStatus(undefined);
|
||||
sendAnalytics("Library", "Set filter status (all)");
|
||||
},
|
||||
@ -382,12 +368,11 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
text={langui.reset_all_filters}
|
||||
icon={Icon.Replay}
|
||||
onClick={() => {
|
||||
setSearchName(DEFAULT_FILTERS_STATE.searchName);
|
||||
setQuery(DEFAULT_FILTERS_STATE.query);
|
||||
setShowSubitems(DEFAULT_FILTERS_STATE.showSubitems);
|
||||
setShowPrimaryItems(DEFAULT_FILTERS_STATE.showPrimaryItems);
|
||||
setShowSecondaryItems(DEFAULT_FILTERS_STATE.showSecondaryItems);
|
||||
setSortingMethod(DEFAULT_FILTERS_STATE.sortingMethod);
|
||||
setGroupingMethod(DEFAULT_FILTERS_STATE.groupingMethod);
|
||||
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
|
||||
setFilterUserStatus(DEFAULT_FILTERS_STATE.filterUserStatus);
|
||||
sendAnalytics("Library", "Reset all filters");
|
||||
@ -398,53 +383,45 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
|
||||
const contentPanel = (
|
||||
<ContentPanel width={ContentPanelWidthSizes.Full}>
|
||||
<SmartList
|
||||
items={filterHasAttributes(items, ["id", "attributes"] as const)}
|
||||
getItemId={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<PreviewCard
|
||||
href={`/library/${item.attributes.slug}`}
|
||||
title={item.attributes.title}
|
||||
subtitle={item.attributes.subtitle}
|
||||
thumbnail={item.attributes.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio="21/29.7"
|
||||
thumbnailRounded={false}
|
||||
keepInfoVisible={keepInfoVisible}
|
||||
topChips={
|
||||
item.attributes.metadata &&
|
||||
item.attributes.metadata.length > 0 &&
|
||||
item.attributes.metadata[0]
|
||||
? [prettyItemSubType(item.attributes.metadata[0])]
|
||||
: []
|
||||
}
|
||||
bottomChips={item.attributes.categories?.data.map(
|
||||
(category) => category.attributes?.short ?? ""
|
||||
)}
|
||||
metadata={{
|
||||
releaseDate: item.attributes.release_date,
|
||||
price: item.attributes.price,
|
||||
position: "Bottom",
|
||||
}}
|
||||
infoAppend={
|
||||
!isUntangibleGroupItem(item.attributes.metadata?.[0]) && (
|
||||
<PreviewCardCTAs id={item.id} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
className={cJoin(
|
||||
"grid-cols-2 items-end",
|
||||
cIf(isContentPanelAtLeast4xl, "grid-cols-[repeat(auto-fill,_minmax(13rem,1fr))]")
|
||||
)}
|
||||
searchingTerm={searchName}
|
||||
sortingFunction={sortingFunction}
|
||||
groupingFunction={groupingFunction}
|
||||
searchingBy={(item) =>
|
||||
prettyInlineTitle("", item.attributes.title, item.attributes.subtitle)
|
||||
}
|
||||
filteringFunction={filteringFunction}
|
||||
paginationItemPerPage={25}
|
||||
/>
|
||||
<Paginator page={page} onPageChange={setPage} totalNumberOfPages={libraryItems?.totalPages}>
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fill,_minmax(12rem,1fr))] items-end
|
||||
gap-x-6 gap-y-8">
|
||||
{libraryItems?.hits.map((item) => (
|
||||
<PreviewCard
|
||||
key={item.id}
|
||||
href={`/library/${item.slug}`}
|
||||
title={item._formatted.title}
|
||||
subtitle={item._formatted.subtitle}
|
||||
description={
|
||||
item._matchesPosition.descriptions && item._matchesPosition.descriptions.length > 0
|
||||
? item._formatted.descriptions?.[0]
|
||||
: undefined
|
||||
}
|
||||
thumbnail={item.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio="21/29.7"
|
||||
thumbnailRounded={false}
|
||||
keepInfoVisible={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",
|
||||
}}
|
||||
infoAppend={
|
||||
!isUntangibleGroupItem(item.metadata?.[0]) && <PreviewCardCTAs id={item.id} />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Paginator>
|
||||
</ContentPanel>
|
||||
);
|
||||
|
||||
@ -453,7 +430,7 @@ const Library = ({ items, ...otherProps }: Props): JSX.Element => {
|
||||
subPanel={subPanel}
|
||||
contentPanel={contentPanel}
|
||||
subPanelIcon={Icon.Search}
|
||||
{...otherProps}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -464,19 +441,40 @@ export default Library;
|
||||
* ───────────────────────────────────╯ NEXT DATA FETCHING ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (context) => {
|
||||
const sdk = getReadySdk();
|
||||
export const getStaticProps: GetStaticProps = (context) => {
|
||||
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 = {
|
||||
items: items.libraryItems.data,
|
||||
openGraph: getOpenGraph(langui, langui.library ?? "Library"),
|
||||
};
|
||||
return {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
@ -1,31 +1,31 @@
|
||||
import { GetStaticProps } from "next";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useBoolean } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
import { AppLayout, AppLayoutRequired } from "components/AppLayout";
|
||||
import { Switch } from "components/Inputs/Switch";
|
||||
import { PanelHeader } from "components/PanelComponents/PanelHeader";
|
||||
import { ContentPanel, ContentPanelWidthSizes } from "components/Containers/ContentPanel";
|
||||
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 { TextInput } from "components/Inputs/TextInput";
|
||||
import { Button } from "components/Inputs/Button";
|
||||
import { useDeviceSupportsHover } from "hooks/useMediaQuery";
|
||||
import { filterHasAttributes, isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
import { SmartList } from "components/SmartList";
|
||||
import { filterHasAttributes, isDefined, isDefinedAndNotEmpty } from "helpers/asserts";
|
||||
import { getOpenGraph } from "helpers/openGraph";
|
||||
import { compareDate } from "helpers/date";
|
||||
import { TranslatedPreviewCard } from "components/PreviewCard";
|
||||
import { HorizontalLine } from "components/HorizontalLine";
|
||||
import { cIf } from "helpers/className";
|
||||
import { getLangui } from "graphql/fetchLocalData";
|
||||
import { sendAnalytics } from "helpers/analytics";
|
||||
import { Terminal } from "components/Cli/Terminal";
|
||||
import { atoms } from "contexts/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,31 +33,74 @@ import { useAtomGetter } from "helpers/atoms";
|
||||
*/
|
||||
|
||||
const DEFAULT_FILTERS_STATE = {
|
||||
searchName: "",
|
||||
query: "",
|
||||
keepInfoVisible: true,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
const queryParamSchema = z.object({
|
||||
query: z.coerce.string().optional(),
|
||||
page: z.coerce.number().positive().optional(),
|
||||
});
|
||||
|
||||
/*
|
||||
* ╭────────╮
|
||||
* ──────────────────────────────────────────╯ PAGE ╰─────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
interface Props extends AppLayoutRequired {
|
||||
posts: NonNullable<GetPostsPreviewQuery["posts"]>["data"];
|
||||
}
|
||||
interface Props extends AppLayoutRequired {}
|
||||
|
||||
const News = ({ posts, ...otherProps }: Props): JSX.Element => {
|
||||
const isContentPanelAtLeast4xl = useAtomGetter(atoms.containerQueries.isContentPanelAtLeast4xl);
|
||||
const News = ({ ...otherProps }: Props): JSX.Element => {
|
||||
const langui = useAtomGetter(atoms.localData.langui);
|
||||
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 {
|
||||
value: keepInfoVisible,
|
||||
toggle: toggleKeepInfoVisible,
|
||||
setValue: setKeepInfoVisible,
|
||||
} = useBoolean(DEFAULT_FILTERS_STATE.keepInfoVisible);
|
||||
|
||||
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"],
|
||||
});
|
||||
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 = (
|
||||
<SubPanel>
|
||||
<PanelHeader icon={Icon.Feed} title={langui.news} description={langui.news_description} />
|
||||
@ -67,9 +110,9 @@ const News = ({ posts, ...otherProps }: Props): JSX.Element => {
|
||||
<TextInput
|
||||
className="mb-6 w-full"
|
||||
placeholder={langui.search_title ?? "Search..."}
|
||||
value={searchName}
|
||||
value={query}
|
||||
onChange={(name) => {
|
||||
setSearchName(name);
|
||||
setQuery(name);
|
||||
if (isDefinedAndNotEmpty(name)) {
|
||||
sendAnalytics("News", "Change search term");
|
||||
} else {
|
||||
@ -95,7 +138,7 @@ const News = ({ posts, ...otherProps }: Props): JSX.Element => {
|
||||
text={langui.reset_all_filters}
|
||||
icon={Icon.Replay}
|
||||
onClick={() => {
|
||||
setSearchName(DEFAULT_FILTERS_STATE.searchName);
|
||||
setQuery(DEFAULT_FILTERS_STATE.query);
|
||||
setKeepInfoVisible(DEFAULT_FILTERS_STATE.keepInfoVisible);
|
||||
sendAnalytics("News", "Reset all filters");
|
||||
}}
|
||||
@ -105,59 +148,47 @@ const News = ({ posts, ...otherProps }: Props): JSX.Element => {
|
||||
|
||||
const contentPanel = (
|
||||
<ContentPanel width={ContentPanelWidthSizes.Full}>
|
||||
<SmartList
|
||||
items={filterHasAttributes(posts, ["attributes", "id"] as const)}
|
||||
getItemId={(post) => post.id}
|
||||
renderItem={({ item: post }) => (
|
||||
<TranslatedPreviewCard
|
||||
href={`/news/${post.attributes.slug}`}
|
||||
translations={filterHasAttributes(post.attributes.translations, [
|
||||
"language.data.attributes.code",
|
||||
] as const).map((translation) => ({
|
||||
language: translation.language.data.attributes.code,
|
||||
title: translation.title,
|
||||
description: translation.excerpt,
|
||||
}))}
|
||||
fallback={{ title: prettySlug(post.attributes.slug) }}
|
||||
thumbnail={post.attributes.thumbnail?.data?.attributes}
|
||||
thumbnailAspectRatio="3/2"
|
||||
thumbnailForceAspectRatio
|
||||
bottomChips={post.attributes.categories?.data.map(
|
||||
(category) => category.attributes?.short ?? ""
|
||||
)}
|
||||
keepInfoVisible={keepInfoVisible}
|
||||
metadata={{
|
||||
releaseDate: post.attributes.date,
|
||||
releaseDateFormat: "long",
|
||||
position: "Top",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
className={cIf(
|
||||
isContentPanelAtLeast4xl,
|
||||
"grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] gap-x-6 gap-y-8",
|
||||
"grid-cols-2 gap-x-4 gap-y-6"
|
||||
)}
|
||||
searchingTerm={searchName}
|
||||
searchingBy={(post) =>
|
||||
`${prettySlug(post.attributes.slug)} ${post.attributes.translations
|
||||
?.map((translation) => translation?.title)
|
||||
.join(" ")}`
|
||||
}
|
||||
paginationItemPerPage={25}
|
||||
/>
|
||||
<Paginator page={page} onPageChange={setPage} totalNumberOfPages={posts?.totalPages}>
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fill,_minmax(15rem,1fr))] items-start
|
||||
gap-x-6 gap-y-8">
|
||||
{posts?.hits.map((item) => (
|
||||
<TranslatedPreviewCard
|
||||
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={keepInfoVisible}
|
||||
bottomChips={item.categories?.data.map(
|
||||
(category) => category.attributes?.short ?? ""
|
||||
)}
|
||||
metadata={{
|
||||
releaseDate: item.date,
|
||||
releaseDateFormat: "long",
|
||||
position: "Top",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Paginator>
|
||||
</ContentPanel>
|
||||
);
|
||||
|
||||
if (isTerminalMode) {
|
||||
return (
|
||||
<Terminal
|
||||
parentPath="/"
|
||||
childrenPaths={filterHasAttributes(posts, ["attributes"] as const).map(
|
||||
(post) => post.attributes.slug
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <Terminal parentPath="/" childrenPaths={posts?.hits.map((post) => post.slug) ?? []} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -176,25 +207,12 @@ export default News;
|
||||
* ───────────────────────────────────╯ NEXT DATA FETCHING ╰──────────────────────────────────────
|
||||
*/
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (context) => {
|
||||
const sdk = getReadySdk();
|
||||
export const getStaticProps: GetStaticProps = (context) => {
|
||||
const langui = getLangui(context.locale);
|
||||
const posts = await sdk.getPostsPreview();
|
||||
if (!posts.posts) return { notFound: true };
|
||||
|
||||
const props: Props = {
|
||||
posts: sortPosts(posts.posts.data),
|
||||
openGraph: getOpenGraph(langui, langui.news ?? "News"),
|
||||
};
|
||||
return {
|
||||
props: props,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* ╭───────────────────╮
|
||||
* ─────────────────────────────────────╯ PRIVATE METHODS ╰───────────────────────────────────────
|
||||
*/
|
||||
|
||||
const sortPosts = (posts: Props["posts"]): Props["posts"] =>
|
||||
posts.sort((a, b) => compareDate(a.attributes?.date, b.attributes?.date)).reverse();
|
||||
|
7248
src/shared/meilisearch-graphql-typings/generated.ts
Normal file
7248
src/shared/meilisearch-graphql-typings/generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
73
src/shared/meilisearch-graphql-typings/meiliTypes.ts
Normal file
73
src/shared/meilisearch-graphql-typings/meiliTypes.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import {
|
||||
ContentAttributesFragment,
|
||||
GetContentQuery,
|
||||
GetLibraryItemQuery,
|
||||
GetPostQuery,
|
||||
GetVideoQuery,
|
||||
LibraryItemAttributesFragment,
|
||||
PostAttributesFragment,
|
||||
VideoAttributesFragment,
|
||||
} from "./generated";
|
||||
|
||||
export interface MeiliLibraryItem extends Omit<LibraryItemAttributesFragment, "descriptions"> {
|
||||
id: string;
|
||||
descriptions: string[];
|
||||
}
|
||||
|
||||
export interface MeiliContent extends Omit<ContentAttributesFragment, "translations"> {
|
||||
id: string;
|
||||
translations?: Array<{
|
||||
__typename?: "ComponentTranslationsTitle";
|
||||
pre_title?: string | null;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
description?: string | null;
|
||||
language?: {
|
||||
__typename?: "LanguageEntityResponse";
|
||||
data?: {
|
||||
__typename?: "LanguageEntity";
|
||||
attributes?: { __typename?: "Language"; code: string } | null;
|
||||
} | null;
|
||||
} | null;
|
||||
} | null> | null;
|
||||
}
|
||||
|
||||
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 enum MeiliIndices {
|
||||
LIBRARY_ITEM = "library-item",
|
||||
CONTENT = "content",
|
||||
VIDEOS = "video",
|
||||
POST = "post",
|
||||
}
|
||||
|
||||
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"];
|
||||
};
|
@ -23,8 +23,14 @@ h6 {
|
||||
@apply bg-dark text-light;
|
||||
}
|
||||
|
||||
/* MARKS */
|
||||
|
||||
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 */
|
||||
|
@ -5,7 +5,7 @@ const rgb = (color) => [color.r, color.g, color.b].join(" ");
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: "class",
|
||||
darkMode: ["class", ".set-theme-dark"],
|
||||
content: ["./src/**/*.{tsx,ts}"],
|
||||
theme: {
|
||||
colors: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user