Compare commits

..

1 Commits

Author SHA1 Message Date
DrMint ccc81a122c Some experimentation with facetted search 2023-08-17 12:53:05 +02:00
15 changed files with 5186 additions and 4479 deletions

View File

@ -161,6 +161,7 @@ module.exports = {
"@typescript-eslint/no-invalid-void-type": "error", "@typescript-eslint/no-invalid-void-type": "error",
"@typescript-eslint/no-meaningless-void-operator": "error", "@typescript-eslint/no-meaningless-void-operator": "error",
"@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error", "@typescript-eslint/no-non-null-asserted-nullish-coalescing": "error",
"@typescript-eslint/no-parameter-properties": "error",
"@typescript-eslint/no-require-imports": "error", "@typescript-eslint/no-require-imports": "error",
// "@typescript-eslint/no-type-alias": "warn", // "@typescript-eslint/no-type-alias": "warn",
"@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn", "@typescript-eslint/no-unnecessary-boolean-literal-compare": "warn",
@ -181,6 +182,7 @@ module.exports = {
"@typescript-eslint/prefer-string-starts-ends-with": "error", "@typescript-eslint/prefer-string-starts-ends-with": "error",
"@typescript-eslint/promise-function-async": "error", "@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-array-sort-compare": "error", "@typescript-eslint/require-array-sort-compare": "error",
"@typescript-eslint/sort-type-union-intersection-members": "warn",
// "@typescript-eslint/strict-boolean-expressions": [ // "@typescript-eslint/strict-boolean-expressions": [
// "error", // "error",
// { allowAny: true }, // { allowAny: true },
@ -190,6 +192,7 @@ module.exports = {
"@typescript-eslint/unified-signatures": "error", "@typescript-eslint/unified-signatures": "error",
/* EXTENSION OF ESLINT */ /* EXTENSION OF ESLINT */
"@typescript-eslint/no-duplicate-imports": "error",
"@typescript-eslint/default-param-last": "warn", "@typescript-eslint/default-param-last": "warn",
"@typescript-eslint/dot-notation": "warn", "@typescript-eslint/dot-notation": "warn",
"@typescript-eslint/init-declarations": "warn", "@typescript-eslint/init-declarations": "warn",

View File

@ -3,3 +3,5 @@ interactive: true
format: "group" format: "group"
reject: reject:
- "react-hotkeys-hook" # we are stuck at version 3.4.7 because 4.X is not working well. Need more experimenting. - "react-hotkeys-hook" # we are stuck at version 3.4.7 because 4.X is not working well. Need more experimenting.
- "graphql-request" # we are stuck at version 5.1.0 because 5.2.0 has a typescript bug see https://github.com/dotansimha/graphql-code-generator/issues/9046
- "@graphql-codegen/typescript-graphql-request" # same as for "graphql-request"

6345
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -21,71 +21,71 @@
"upgrade": "ncu" "upgrade": "ncu"
}, },
"dependencies": { "dependencies": {
"@fontsource/noto-serif-jp": "^5.0.7", "@fontsource/noto-serif-jp": "^5.0.2",
"@fontsource/opendyslexic": "^5.0.7", "@fontsource/opendyslexic": "^5.0.2",
"@fontsource/share-tech-mono": "^5.0.8", "@fontsource/share-tech-mono": "^5.0.2",
"@fontsource/vollkorn": "^5.0.9", "@fontsource/vollkorn": "^5.0.2",
"@fontsource/zen-maru-gothic": "^5.0.7", "@fontsource/zen-maru-gothic": "^5.0.2",
"@formatjs/icu-messageformat-parser": "^2.6.0", "@formatjs/icu-messageformat-parser": "^2.4.0",
"@tippyjs/react": "^4.2.6", "@tippyjs/react": "^4.2.6",
"autoprefixer": "^10.4.15", "autoprefixer": "^10.4.14",
"cuid": "^2.1.8", "cuid": "^2.1.8",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"intl-messageformat": "^10.5.0", "intl-messageformat": "^10.3.5",
"isomorphic-dompurify": "^1.8.0", "isomorphic-dompurify": "^1.6.0",
"jotai": "^2.3.1", "jotai": "^2.1.1",
"markdown-to-jsx": "^7.3.2", "markdown-to-jsx": "^7.2.1",
"marked": "^7.0.3", "marked": "^4.3.0",
"material-symbols": "^0.10.4", "material-symbols": "^0.5.5",
"meilisearch": "^0.34.1", "meilisearch": "^0.33.0",
"next": "^13.4.17", "next": "^13.4.4",
"nodemailer": "^6.9.4", "nodemailer": "^6.9.3",
"patch-package": "^8.0.0", "patch-package": "^7.0.0",
"rc-slider": "^10.2.1", "rc-slider": "^10.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-collapsible": "^2.10.0", "react-collapsible": "^2.10.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hotkeys-hook": "^3.4.7", "react-hotkeys-hook": "^3.4.7",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
"react-zoom-pan-pinch": "^3.1.0", "react-zoom-pan-pinch": "^3.0.8",
"string-natural-compare": "^3.0.1", "string-natural-compare": "^3.0.1",
"throttle-debounce": "^5.0.0", "throttle-debounce": "^5.0.0",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.1.2", "turndown": "^7.1.2",
"ua-parser-js": "^1.0.35", "ua-parser-js": "^1.0.35",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"zod": "^3.22.1" "zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@digitak/esrun": "3.2.24", "@digitak/esrun": "3.2.24",
"@graphql-codegen/cli": "5.0.0", "@graphql-codegen/cli": "^3.3.1",
"@graphql-codegen/typescript": "4.0.1", "@graphql-codegen/typescript": "3.0.4",
"@graphql-codegen/typescript-graphql-request": "5.0.0", "@graphql-codegen/typescript-graphql-request": "^4.5.9",
"@graphql-codegen/typescript-operations": "4.0.1", "@graphql-codegen/typescript-operations": "^3.0.4",
"@types/html-to-text": "^9.0.1", "@types/html-to-text": "^9.0.1",
"@types/marked": "^5.0.1", "@types/marked": "^4.3.0",
"@types/node": "20.5.0", "@types/node": "20.2.5",
"@types/nodemailer": "^6.4.9", "@types/nodemailer": "^6.4.8",
"@types/react": "^18.2.20", "@types/react": "^18.2.9",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.4",
"@types/string-natural-compare": "^3.0.2", "@types/string-natural-compare": "^3.0.2",
"@types/throttle-debounce": "^5.0.0", "@types/throttle-debounce": "^5.0.0",
"@types/turndown": "^5.0.1", "@types/turndown": "^5.0.1",
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^6.4.0", "@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^6.4.0", "@typescript-eslint/parser": "^5.59.9",
"chalk": "^5.3.0", "chalk": "^5.2.0",
"dotenv": "^16.3.1", "dotenv": "^16.1.4",
"eslint": "^8.47.0", "eslint": "^8.42.0",
"eslint-config-next": "13.4.17", "eslint-config-next": "13.4.4",
"eslint-plugin-import": "^2.28.0", "eslint-plugin-import": "^2.27.5",
"graphql": "16.8.0", "graphql": "^16.6.0",
"graphql-request": "6.1.0", "graphql-request": "5.1.0",
"next-sitemap": "^4.2.2", "next-sitemap": "^4.1.3",
"prettier": "^3.0.2", "prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.5.3", "prettier-plugin-tailwindcss": "^0.3.0",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.2",
"ts-unused-exports": "^10.0.0", "ts-unused-exports": "^9.0.4",
"typescript": "^5.1.6" "typescript": "^5.1.3"
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,11 +19,10 @@ interface Props {
export const ChroniclesLists = ({ chapters, currentChronicleSlug }: Props): JSX.Element => { export const ChroniclesLists = ({ chapters, currentChronicleSlug }: Props): JSX.Element => {
const [openedIndex, setOpenedIndex] = useState( const [openedIndex, setOpenedIndex] = useState(
currentChronicleSlug currentChronicleSlug
? chapters.findIndex( ? chapters.findIndex((chapter) =>
(chapter) => chapter.attributes?.chronicles?.data.some(
chapter.attributes?.chronicles?.data.some( (chronicle) => chronicle.attributes?.slug === currentChronicleSlug
(chronicle) => chronicle.attributes?.slug === currentChronicleSlug )
)
) )
: -1 : -1
); );

View File

@ -14,7 +14,6 @@ import { TranslatedProps } from "types/TranslatedProps";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { useAtomGetter } from "helpers/atoms"; import { useAtomGetter } from "helpers/atoms";
import { useFormat } from "hooks/useFormat"; import { useFormat } from "hooks/useFormat";
import { isDefined } from "helpers/asserts";
/* /*
* *
@ -85,7 +84,7 @@ export const PreviewCard = ({
const metadataJSX = ( const metadataJSX = (
<> <>
{metadata && (isDefined(metadata.releaseDate) || isDefined(metadata.price)) && ( {metadata && (metadata.releaseDate || metadata.price) && (
<div className="flex w-full flex-row flex-wrap gap-x-3"> <div className="flex w-full flex-row flex-wrap gap-x-3">
{metadata.releaseDate && ( {metadata.releaseDate && (
<p className="text-sm"> <p className="text-sm">

View File

@ -1,6 +1,6 @@
import { convert } from "html-to-text"; import { convert } from "html-to-text";
import { sanitize } from "isomorphic-dompurify"; import { sanitize } from "isomorphic-dompurify";
import { Renderer, marked } from "marked"; import { marked } from "marked";
import { isDefinedAndNotEmpty } from "./asserts"; import { isDefinedAndNotEmpty } from "./asserts";
export const prettySlug = (slug?: string, parentSlug?: string): string => { export const prettySlug = (slug?: string, parentSlug?: string): string => {
@ -101,7 +101,7 @@ export const prettyMarkdown = (markdown: string): string => {
const newline = () => "\n"; const newline = () => "\n";
const empty = () => ""; const empty = () => "";
const TxtRenderer: Renderer = { const TxtRenderer: marked.Renderer = {
// Block elements // Block elements
code: escapeBlock, code: escapeBlock,
blockquote: block, blockquote: block,

View File

@ -1,5 +1,3 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { MeiliSearch } from "meilisearch"; import { MeiliSearch } from "meilisearch";
import type { import type {
SearchParams, SearchParams,
@ -75,6 +73,7 @@ export const filterHitsWithHighlight = <T extends MeiliDocumentsType["documents"
return result; return result;
}; };
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const meiliSearch = async <I extends MeiliDocumentsType["index"]>( export const meiliSearch = async <I extends MeiliDocumentsType["index"]>(
indexName: I, indexName: I,
query: string, query: string,
@ -92,5 +91,24 @@ export const meiliSearch = async <I extends MeiliDocumentsType["index"]>(
})) as unknown as CustomSearchResponse<Extract<MeiliDocumentsType, { index: I }>["documents"]>; })) as unknown as CustomSearchResponse<Extract<MeiliDocumentsType, { index: I }>["documents"]>;
}; };
export type MeiliFacetResult = { name: string; count: number }[];
export const meiliFacet = async <I extends MeiliDocumentsType["index"]>(
indexName: I,
facet: string
): Promise<MeiliFacetResult> => {
const index = meili.index(indexName);
const searchResult = await index.search<Extract<MeiliDocumentsType, { index: I }>["documents"]>(
"",
{
hitsPerPage: 0,
facets: [facet],
}
);
return [...Object.entries(searchResult.facetDistribution?.[facet] ?? {})].map(
([name, count]) => ({ name, count })
);
};
export const containsHighlight = (text: string | null | undefined): boolean => export const containsHighlight = (text: string | null | undefined): boolean =>
isDefined(text) && text.includes("</mark>"); isDefined(text) && text.includes("</mark>");

View File

@ -2,6 +2,7 @@ import { GetStaticProps } from "next";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useBoolean } from "usehooks-ts"; import { useBoolean } from "usehooks-ts";
import { z } from "zod"; import { z } from "zod";
import Collapsible from "react-collapsible";
import { AppLayout, AppLayoutRequired } from "components/AppLayout"; import { AppLayout, AppLayoutRequired } from "components/AppLayout";
import { Select } from "components/Inputs/Select"; import { Select } from "components/Inputs/Select";
import { Switch } from "components/Inputs/Switch"; import { Switch } from "components/Inputs/Switch";
@ -20,6 +21,8 @@ import {
containsHighlight, containsHighlight,
CustomSearchResponse, CustomSearchResponse,
filterHitsWithHighlight, filterHitsWithHighlight,
meiliFacet,
MeiliFacetResult,
meiliSearch, meiliSearch,
} from "helpers/search"; } from "helpers/search";
import { MeiliContent, MeiliIndices } from "shared/meilisearch-graphql-typings/meiliTypes"; import { MeiliContent, MeiliIndices } from "shared/meilisearch-graphql-typings/meiliTypes";
@ -31,6 +34,7 @@ import { useFormat } from "hooks/useFormat";
import { getFormat } from "helpers/i18n"; import { getFormat } from "helpers/i18n";
import { useAtomGetter, useAtomSetter } from "helpers/atoms"; import { useAtomGetter, useAtomSetter } from "helpers/atoms";
import { atoms } from "contexts/atoms"; import { atoms } from "contexts/atoms";
import { Ico } from "components/Ico";
/* /*
* *
@ -75,17 +79,6 @@ const Contents = (props: Props): JSX.Element => {
[format] [format]
); );
const languageOptions = useMemo(() => {
const memo =
router.locales?.map((language) => ({
meiliAttribute: language,
displayedName: formatLanguage(language),
})) ?? [];
memo.unshift({ meiliAttribute: "", displayedName: format("all") });
return memo;
}, [router.locales, formatLanguage, format]);
const [sortingMethod, setSortingMethod] = useState<number>( const [sortingMethod, setSortingMethod] = useState<number>(
router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod router.query.sort ?? DEFAULT_FILTERS_STATE.sortingMethod
); );
@ -103,14 +96,15 @@ const Contents = (props: Props): JSX.Element => {
router.query.lang ?? DEFAULT_FILTERS_STATE.lang router.query.lang ?? DEFAULT_FILTERS_STATE.lang
); );
const [selectedLocales, setSelectedLocales] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
const fetchPosts = async () => { const fetchPosts = async () => {
const currentSortingMethod = sortingMethods[sortingMethod]?.meiliAttribute; const currentSortingMethod = sortingMethods[sortingMethod]?.meiliAttribute;
const currentLanguageOption = languageOptions[languageOption]?.meiliAttribute;
const filter: string[] = []; const filter: string[] = [];
if (languageOption !== 0) { if (selectedLocales.length !== 0) {
filter.push(`filterable_languages = ${currentLanguageOption}`); filter.push(`filterable_languages IN [${selectedLocales.join()}]`);
} }
const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, { const searchResult = await meiliSearch(MeiliIndices.CONTENT, query, {
@ -123,14 +117,10 @@ const Contents = (props: Props): JSX.Element => {
sort: isDefined(currentSortingMethod) ? [currentSortingMethod] : undefined, sort: isDefined(currentSortingMethod) ? [currentSortingMethod] : undefined,
}); });
setContents( setContents(filterHitsWithHighlight<MeiliContent>(searchResult, "translations"));
languageOption === 0
? filterHitsWithHighlight<MeiliContent>(searchResult, "translations")
: searchResult
);
}; };
fetchPosts(); fetchPosts();
}, [query, page, sortingMethod, sortingMethods, languageOption, languageOptions]); }, [query, page, sortingMethod, sortingMethods, selectedLocales]);
useEffect(() => { useEffect(() => {
if (router.isReady) if (router.isReady)
@ -153,6 +143,14 @@ const Contents = (props: Props): JSX.Element => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady]); }, [router.isReady]);
const [countForLanguages, setCountForLanguages] = useState<MeiliFacetResult>([]);
const [countForCategories, setCountForCategories] = useState<MeiliFacetResult>([]);
useEffect(() => {
meiliFacet(MeiliIndices.CONTENT, "filterable_languages").then(setCountForLanguages);
meiliFacet(MeiliIndices.CONTENT, "filterable_categories").then(setCountForCategories);
}, []);
const searchInput = ( const searchInput = (
<TextInput <TextInput
placeholder={format("search_placeholder")} placeholder={format("search_placeholder")}
@ -190,6 +188,22 @@ const Contents = (props: Props): JSX.Element => {
{!is1ColumnLayout && <div className="mb-6">{searchInput}</div>} {!is1ColumnLayout && <div className="mb-6">{searchInput}</div>}
<CollapsibleFilters
title={format("language", { count: countForLanguages.length })}
facetResult={countForLanguages}
format={formatLanguage}
onValueChanged={setSelectedLocales}
selectedValues={selectedLocales}
/>
<CollapsibleFilters
title={format("category", { count: countForCategories.length })}
facetResult={countForCategories}
format={formatCategory}
onValueChanged={setSelectedLocales}
selectedValues={selectedLocales}
/>
<WithLabel label={format("order_by")}> <WithLabel label={format("order_by")}>
<Select <Select
className="w-full" className="w-full"
@ -208,24 +222,6 @@ const Contents = (props: Props): JSX.Element => {
/> />
</WithLabel> </WithLabel>
<WithLabel label={format("language", { count: Infinity })}>
<Select
className="w-full"
options={languageOptions.map((item) => item.displayedName)}
value={languageOption}
onChange={(newLanguageOption) => {
setPage(1);
setLanguageOption(newLanguageOption);
sendAnalytics(
"Contents/All",
`Change language filter (${
languageOptions.map((item) => item.meiliAttribute)[newLanguageOption]
})`
);
}}
/>
</WithLabel>
{hoverable && ( {hoverable && (
<WithLabel label={format("always_show_info")}> <WithLabel label={format("always_show_info")}>
<Switch <Switch
@ -277,8 +273,9 @@ const Contents = (props: Props): JSX.Element => {
})) }))
.filter( .filter(
({ language }) => ({ language }) =>
languageOption === 0 || selectedLocales.length === 0 ||
language === languageOptions[languageOption]?.meiliAttribute query !== "" ||
selectedLocales.includes(language)
)} )}
fallback={{ title: prettySlug(item.slug) }} fallback={{ title: prettySlug(item.slug) }}
thumbnail={item.thumbnail?.data?.attributes} thumbnail={item.thumbnail?.data?.attributes}
@ -321,3 +318,63 @@ export const getStaticProps: GetStaticProps = (context) => {
props: props, props: props,
}; };
}; };
/*
*
* PRIVATE COMPONENTS
*/
interface CollapsibleFiltersProps {
title: string;
facetResult: MeiliFacetResult;
selectedValues: string[];
onValueChanged: (setStateFn: (current: string[]) => string[]) => void;
format: (name: string) => string;
}
const CollapsibleFilters = ({
title,
facetResult,
selectedValues,
onValueChanged,
format,
}: CollapsibleFiltersProps): JSX.Element => {
const [isOpened, setOpened] = useState(false);
if (facetResult.length === 0) return <></>;
return (
<Collapsible
open={isOpened}
onTriggerClosing={() => setOpened(false)}
onOpening={() => setOpened(true)}
trigger={
<div className="flex gap-2">
<p className="leading-5">{title}</p>
<Ico icon={isOpened ? "expand_less" : "expand_more"} />
</div>
}
easing="ease-in-out"
transitionTime={400}
contentInnerClassName="flex flex-wrap gap-1 py-3"
overflowWhenOpen="visible">
{facetResult
.filter(({ count }) => count > 0)
.map(({ name, count }) => (
<Button
key={name}
text={`${format(name)} (${count})`}
size="small"
onClick={() =>
onValueChanged((current) => {
if (current.includes(name)) {
return current.filter((currentLocale) => currentLocale !== name);
}
return [...current, name];
})
}
active={selectedValues.includes(name)}
/>
))}
</Collapsible>
);
};

View File

@ -807,7 +807,9 @@ const ContentItem = ({
<div className="grid grid-cols-[auto_auto_1fr_auto] items-center gap-3"> <div className="grid grid-cols-[auto_auto_1fr_auto] items-center gap-3">
<h3>{title}</h3> <h3>{title}</h3>
<div className="flex flex-wrap place-content-center gap-1"> <div className="flex flex-wrap place-content-center gap-1">
{content?.categories?.map((category, index) => <Chip key={index} text={category} />)} {content?.categories?.map((category, index) => (
<Chip key={index} text={category} />
))}
</div> </div>
<p className="h-4 w-full border-b-2 border-dotted border-mid" /> <p className="h-4 w-full border-b-2 border-dotted border-mid" />
{content?.type && <Chip className="justify-self-end" text={content.type} />} {content?.type && <Chip className="justify-self-end" text={content.type} />}

View File

@ -56,7 +56,7 @@ const WikiPage = ({ page, ...otherProps }: Props): JSX.Element => {
const toc = getTocFromMarkdawn(selectedTranslation?.body?.body, selectedTranslation?.title); const toc = getTocFromMarkdawn(selectedTranslation?.body?.body, selectedTranslation?.title);
const subPanel = const subPanel =
isDefined(toc) || !is1ColumnLayout ? ( toc || !is1ColumnLayout ? (
<SubPanel> <SubPanel>
<ElementsSeparator> <ElementsSeparator>
{[ {[
@ -204,18 +204,18 @@ const WikiPage = ({ page, ...otherProps }: Props): JSX.Element => {
page.definitions && page.definitions.length > 0 page.definitions && page.definitions.length > 0
? `${filterHasAttributes(page.definitions, ["translations"]).map( ? `${filterHasAttributes(page.definitions, ["translations"]).map(
(definition, index) => (definition, index) =>
`${prettyTerminalUnderlinedTitle( `${prettyTerminalUnderlinedTitle(format("definition_x", { x: index + 1 }))}${
format("definition_x", { x: index + 1 }) staticSmartLanguage({
)}${staticSmartLanguage({ items: filterHasAttributes(definition.translations, [
items: filterHasAttributes(definition.translations, [ "language.data.attributes.code",
"language.data.attributes.code", ]),
]), languageExtractor: (item) => item.language.data.attributes.code,
languageExtractor: (item) => item.language.data.attributes.code, preferredLanguages: getDefaultPreferredLanguages(
preferredLanguages: getDefaultPreferredLanguages( router.locale ?? "en",
router.locale ?? "en", router.locales ?? ["en"]
router.locales ?? ["en"] ),
), })?.definition
})?.definition}` }`
)}` )}`
: "" : ""
}${ }${

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,7 @@ export interface MeiliContent
})[]; })[];
sortable_updated_date: number; sortable_updated_date: number;
filterable_languages: string[]; filterable_languages: string[];
filterable_categories: string[];
} }
export interface MeiliVideo extends VideoAttributesFragment { export interface MeiliVideo extends VideoAttributesFragment {