Added search support

This commit is contained in:
DrMint 2024-07-12 00:56:58 +02:00
parent e7eb324cb1
commit 62a89706ec
23 changed files with 610 additions and 30 deletions

View File

@ -18,5 +18,9 @@ CACHE_CONTROL=false
## OPEN EXCHANGE RATE
OER_APP_ID=oerappid5e6ea45ef4e66eaa151612bdcb599df
## MEILI
MEILISEARCH_URL=https://meilisearch.domain.com
MEILISEARCH_MASTER_KEY=some_api_keyqs23d1qs6d54qs897qs3
## ANALYTICS
ANALYTICS_URL=http://analytics.domain.com

View File

@ -118,6 +118,7 @@ Read more:
- Smooth scrolling when using anchor links
- On image pages (scans, gallery, image files), allow the user to navigate to the previous or next image using keyboard arrows.
- On the search page, allow the user to navigate to the previous or next page using keyboard arrows.
- On media pages (scans, images, audios, videos), provide a download button. This way, the user doesn't have to right-click -> "save media as..."

View File

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -12,6 +12,7 @@ interface Props {
backgroundImage?: ComponentProps<typeof AppLayoutBackgroundImg>["img"] | undefined;
hideFooterLinks?: boolean;
hideHomeButton?: boolean;
hideSearchButton?: boolean;
class?: string | undefined;
}
@ -25,6 +26,7 @@ const {
backgroundImage,
hideFooterLinks = false,
hideHomeButton = false,
hideSearchButton = false,
...otherProps
} = Astro.props;
---
@ -34,7 +36,11 @@ const {
<Html openGraph={openGraph}>
{backgroundImage && <AppLayoutBackgroundImg img={backgroundImage} />}
<header>
<Topbar parentPages={parentPages} hideHomeButton={hideHomeButton} />
<Topbar
parentPages={parentPages}
hideHomeButton={hideHomeButton}
hideSearchButton={hideSearchButton}
/>
</header>
<main {...otherProps.class ? otherProps : {}}><slot /></main>
<Footer withLinks={!hideFooterLinks} />

View File

@ -30,7 +30,11 @@ const contactLabel = `${t("footer.socials.contact.title")} - ${t(
{
withLinks && (
<div id="socials" class="when-no-print">
<a href={getLocalizedUrl("/discord")} class="pressable-label" aria-label={discordLabel} title={discordLabel}>
<a
href={getLocalizedUrl("/discord")}
class="pressable-label"
aria-label={discordLabel}
title={discordLabel}>
<Icon name="discord-brands" />
<p class="font-s">{t("footer.socials.discord.title")}</p>
</a>

View File

@ -11,9 +11,10 @@ import type { EndpointSource } from "src/shared/payload/payload-sdk";
interface Props {
parentPages?: EndpointSource[] | undefined;
hideHomeButton?: boolean;
hideSearchButton?: boolean;
}
const { parentPages = [], hideHomeButton = false } = Astro.props;
const { parentPages = [], hideHomeButton = false, hideSearchButton = false } = Astro.props;
const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
---
@ -35,10 +36,16 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
}
<div id="toolbar" class="hide-scrollbar">
<a href={getLocalizedUrl("/search")} aria-label={t("header.topbar.search.tooltip")} hidden>
<Button icon="material-symbols:search" />
</a>
<div class="separator" hidden></div>
{
!hideSearchButton && (
<>
<a href={getLocalizedUrl("/search")} title={t("header.topbar.search.tooltip")}>
<Button icon="material-symbols:search" />
</a>
<div class="separator" />
</>
)
}
<div class="when-no-js">
<a href="/settings">

View File

@ -5,20 +5,21 @@ interface Props {
id?: string;
title?: string | undefined;
icon?: string;
class?: string;
ariaLabel?: string;
class?: string | undefined;
}
const { title, icon, class: className, ariaLabel, id } = Astro.props;
const { title, icon, ariaLabel, id, ...otherProps } = Astro.props;
---
{/* ------------------------------------------- HTML ------------------------------------------- */}
<button
id={id}
class:list={["pressable", { "with-title": !!title }, className]}
class:list={["pressable", { "with-title": !!title }, otherProps.class]}
aria-label={ariaLabel}
title={ariaLabel}>
title={ariaLabel}
{...otherProps.class ? otherProps : {}}>
{icon && <Icon name={icon} />}
{title}
</button>

View File

@ -22,7 +22,11 @@ const { title, description } = Astro.props;
<p>{description}</p>
) : (
<p>
Please contact <a href={getLocalizedUrl("/discord")} target="_blank">website technical administrator</a>.
Please contact{" "}
<a href={getLocalizedUrl("/discord")} target="_blank">
website technical administrator
</a>
.
</p>
)
}

View File

@ -41,6 +41,6 @@ const attributesWithMeta = [
href={getLocalizedUrl(`/audios/${id}`)}
attributes={attributesWithMeta}
icon="material-symbols:music-note"
iconHoverLabel={t("global.previewTypes.audio")}
iconHoverLabel={t("global.collections.audios", { count: 1 })}
smallTitle={title === filename}
/>

View File

@ -65,6 +65,6 @@ if (price) {
href={getLocalizedUrl(`/collectibles/${slug}`)}
attributes={[...attributes, ...additionalAttributes]}
icon="material-symbols:category"
iconHoverLabel={t("global.previewTypes.collectible")}
iconHoverLabel={t("global.collections.collectibles", { count: 1 })}
disableRoundedTop
/>

View File

@ -167,21 +167,21 @@ for (const attribute of attributes) {
height: auto;
&.rounded-top {
border-top-left-radius: 1em;
border-top-right-radius: 1em;
border-top-left-radius: 14px;
border-top-right-radius: 14px;
}
}
& > #icon-container {
&.thumbnail-alt {
margin: 0.4em;
margin: 6px;
margin-bottom: unset;
aspect-ratio: 3/2;
background-color: var(--color-elevation-2);
color: var(--color-base-400);
display: grid;
place-content: center;
border-radius: 0.7em;
border-radius: 8px;
& > svg {
width: 64px;
@ -191,18 +191,18 @@ for (const attribute of attributes) {
&:not(.thumbnail-alt) {
position: absolute;
top: 0.4em;
left: 0.4em;
padding: 0.5em;
top: 6px;
left: 6px;
padding: 8px;
backdrop-filter: blur(5px);
background-color: color-mix(in srgb, var(--color-elevation-2) 60%, transparent);
}
&.rounded-top {
border-radius: 0.7em;
border-radius: 8px;
}
border-bottom-right-radius: 0.7em;
border-bottom-right-radius: 8px;
}
& > #footer {

View File

@ -31,6 +31,6 @@ const { pretitle, title, subtitle, language } =
href={getLocalizedUrl(`/images/${id}`)}
attributes={attributes}
icon="material-symbols:imagesmode"
iconHoverLabel={t("global.previewTypes.image")}
iconHoverLabel={t("global.collections.images", { count: 1 })}
smallTitle={title === filename}
/>

View File

@ -39,5 +39,5 @@ const metaAttributes: Attribute[] = [
href={getLocalizedUrl(`/pages/${slug}`)}
attributes={[...attributes, ...metaAttributes]}
icon="material-symbols:docs"
iconHoverLabel={t("global.previewTypes.page")}
iconHoverLabel={t("global.collections.pages", { count: 1 })}
/>

View File

@ -41,6 +41,6 @@ const attributesWithMeta = [
href={getLocalizedUrl(`/videos/${id}`)}
attributes={attributesWithMeta}
icon="material-symbols:smart-display"
iconHoverLabel={t("global.previewTypes.video")}
iconHoverLabel={t("global.collections.videos", { count: 1 })}
smallTitle={title === filename}
/>

View File

@ -152,4 +152,25 @@ export type WordingKey =
| "global.previewTypes.pdf"
| "collectibles.files"
| "pages.credits.translationLabel"
| "pages.credits.currentLocale";
| "pages.credits.currentLocale"
| "global.media.attributes.resolution"
| "search.searchBar.placeholder"
| "global.collections.collectibles"
| "global.collections.pages"
| "global.collections.folders"
| "global.collections.audios"
| "global.collections.videos"
| "global.collections.images"
| "global.collections.files"
| "global.collections.recorders"
| "global.collections.chronologyEvents"
| "global.collections.all"
| "search.resultCount"
| "search.searchBar.submitButton.tooltip"
| "search.noResult.title"
| "search.noResult.message"
| "search.collectionFilter.tooltip"
| "paginator.goFirstPageButton"
| "paginator.goPreviousPageButton"
| "paginator.goNextPageButton"
| "paginator.goLastPageButton";

View File

@ -19,10 +19,12 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
</a>
</div>
<div id="img-container">
<img src={"/img/search-empty.webp"} width="1054" height="1100" />
<img src={"/img/404-illustration.webp"} width="1054" height="1100" />
</div>
</AppLayout>
{/* ------------------------------------------- CSS -------------------------------------------- */}
<style>
.app {
display: flex;

View File

@ -19,7 +19,11 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
<div id="text-container">
<h1 class="font-serif font-5xl">500</h1>
<h2 class="font-3xl">Server Error</h2>
<p>Please contact <a href={getLocalizedUrl("/discord")} target="_blank">website technical administrator</a>.</p>
<p>
Please contact <a href={getLocalizedUrl("/discord")} target="_blank">
website technical administrator
</a>.
</p>
<a href={getLocalizedUrl("/")}>
<Button icon="material-symbols:home" title={t("home.title")} />
</a>
@ -40,6 +44,8 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
}
</AppLayout>
{/* ------------------------------------------- CSS -------------------------------------------- */}
<style>
.app {
display: flex;

View File

@ -29,7 +29,7 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
<section id="library">
<h2 class="font-serif font-3xl">{t("home.librarySection.title")}</h2>
<p set:html={t("home.librarySection.description")} />
<a href={getLocalizedUrl("/search")} hidden>
<a href={getLocalizedUrl("/search")}>
<Button
class="section-button"
title={t("home.librarySection.button")}
@ -152,6 +152,11 @@ const { t, getLocalizedUrl } = await getI18n(Astro.locals.currentLocale);
}
&#library {
& > a {
display: block;
margin-bottom: 24px;
}
& > .grid {
@media (max-width: 40rem) {
grid-template-columns: 1fr 1fr;

View File

@ -0,0 +1,116 @@
---
import { Icon } from "astro-icon/components";
import { getI18n } from "src/i18n/i18n";
interface Props {
currentPage: number;
pageCount: number;
getUrl: (newPage: number) => string;
}
const { currentPage, getUrl, pageCount } = Astro.props;
const { t, formatNumber } = await getI18n(Astro.locals.currentLocale);
---
{/* ------------------------------------------- HTML ------------------------------------------- */}
{
pageCount > 1 && (
<div class="pagination">
<a
id="first-page-button"
class="pressable"
aria-disabled={currentPage <= 1}
href={getUrl(1)}
title={t("paginator.goFirstPageButton")}>
<Icon name="material-symbols:keyboard-double-arrow-left" />
</a>
<a
id="previous-page-button"
class="pressable"
aria-disabled={currentPage <= 1}
href={getUrl(currentPage - 1)}
title={t("paginator.goPreviousPageButton")}>
<Icon name="material-symbols:chevron-left" />
</a>
<p class="font-l">{`${formatNumber(currentPage)} / ${formatNumber(pageCount)}`}</p>
<a
id="next-page-button"
class="pressable"
aria-disabled={currentPage >= pageCount}
href={getUrl(currentPage + 1)}
title={t("paginator.goNextPageButton")}>
<Icon name="material-symbols:chevron-right" />
</a>
<a
id="last-page-button"
class="pressable"
aria-disabled={currentPage >= pageCount}
href={getUrl(pageCount)}
title={t("paginator.goLastPageButton")}>
<Icon name="material-symbols:double-arrow-rounded" />
</a>
</div>
)
}
{/* ------------------------------------------- CSS -------------------------------------------- */}
<style>
.pagination {
display: flex;
flex-wrap: wrap;
gap: 6px;
place-items: center;
& > p {
margin-inline: 12px;
}
& > a {
border: 2px solid var(--color-base-500);
border-radius: 6px;
padding: 0.5em;
place-content: center;
> svg {
width: 24px;
height: 24px;
}
&.active {
background-color: var(--color-elevation-2);
border-color: var(--color-base-1000);
}
&[aria-disabled] {
pointer-events: none;
touch-action: none;
border-color: var(--color-base-1000);
color: var(--color-base-1000);
opacity: 0.2;
}
}
}
</style>
{/* ------------------------------------------- JS --------------------------------------------- */}
<script>
document.addEventListener("keydown", async (e) => {
const previousPageButton = document.getElementById("previous-page-button");
const nextPageButton = document.getElementById("next-page-button");
if (!previousPageButton) return;
if (!nextPageButton) return;
switch (e.key) {
case "ArrowLeft":
if (!previousPageButton.hasAttribute("aria-disabled")) previousPageButton.click();
break;
case "ArrowRight":
if (!nextPageButton.hasAttribute("aria-disabled")) nextPageButton.click();
break;
}
});
</script>

View File

@ -0,0 +1,282 @@
---
import AppLayout from "components/AppLayout/AppLayout.astro";
import ErrorMessage from "components/ErrorMessage.astro";
import AudioPreview from "components/Previews/AudioPreview.astro";
import FilePreview from "components/Previews/FilePreview.astro";
import ImagePreview from "components/Previews/ImagePreview.astro";
import PagePreview from "components/Previews/PagePreview.astro";
import VideoPreview from "components/Previews/VideoPreview.astro";
import { getI18n } from "src/i18n/i18n";
import { Collections } from "src/shared/payload/payload-sdk";
import { meilisearch } from "src/utils/meilisearch";
import CollectiblePreview from "components/Previews/CollectiblePreview.astro";
import Button from "components/Button.astro";
import Paginator from "./_components/Paginator.astro";
import { Icon } from "astro-icon/components";
import HomeTitle from "../_components/HomeTitle.astro";
type State = {
q: string;
page: number;
type?: string | undefined;
};
const { t, getLocalizedUrl, formatNumber } = await getI18n(Astro.locals.currentLocale);
const reqUrl = new URL(Astro.request.url);
const state: State = {
q: reqUrl.searchParams.get("q") ?? "",
page: parseInt(reqUrl.searchParams.get("page") ?? "1"),
type: reqUrl.searchParams.get("type") ?? undefined,
};
const { hits, page, query, totalHits, totalPages } = await meilisearch.search({
page: state.page,
q: state.q,
types: state.type,
});
const types: { type: Collections; title: string }[] = [
{
type: Collections.Collectibles,
title: t("global.collections.collectibles", { count: Infinity }),
},
{ type: Collections.Pages, title: t("global.collections.pages", { count: Infinity }) },
{ type: Collections.Folders, title: t("global.collections.folders", { count: Infinity }) },
{
type: Collections.Audios,
title: t("global.collections.audios", { count: Infinity }),
},
{
type: Collections.Images,
title: t("global.collections.images", { count: Infinity }),
},
{
type: Collections.Videos,
title: t("global.collections.videos", { count: Infinity }),
},
{
type: Collections.Files,
title: t("global.collections.files", { count: Infinity }),
},
{ type: Collections.Recorders, title: t("global.collections.recorders", { count: Infinity }) },
{
type: Collections.ChronologyEvents,
title: t("global.collections.chronologyEvents"),
},
];
const getSearchUrl = (newState: State): string => {
const searchParams = new URLSearchParams();
if (newState.q !== "") searchParams.set("q", newState.q);
if (newState.page !== 1) searchParams.set("page", newState.page.toString());
if (newState.type) searchParams.set("type", newState.type);
return [getLocalizedUrl("/search"), searchParams.toString()].filter(Boolean).join("?");
};
---
{/* ------------------------------------------- HTML ------------------------------------------- */}
<AppLayout hideSearchButton>
<div class="center">
<HomeTitle />
<form action={getLocalizedUrl("/search")} method="get">
<div class="select-wrapper">
<select name="type" title={t("search.collectionFilter.tooltip")}>
<option value="" selected={state.type === undefined}>
{t("global.collections.all")}
</option>
{
types.map(({ title, type }) => (
<option value={type} selected={type === state.type}>
{title}
</option>
))
}
</select>
<Icon name="material-symbols:keyboard-arrow-down" />
</div>
<input
type="search"
class="pressable"
name="q"
placeholder={t("search.searchBar.placeholder")}
value={query}
/>
<Button
class="submit"
icon="material-symbols:search"
ariaLabel={t("search.searchBar.submitButton.tooltip")}
/>
</form>
<p id="advanced-link" class="font-s">
<!-- Looking for more <a href={getLocalizedUrl("/search/advanced")}>advanced search options</a> -->
</p>
{
totalPages > 1 && (
<>
<Paginator
currentPage={page}
pageCount={totalPages}
getUrl={(newPage) => getSearchUrl({ ...state, page: newPage })}
/>
<p class="font-s">
{t("search.resultCount", {
results: formatNumber(hits.length),
totalResults: formatNumber(totalHits),
})}
</p>
</>
)
}
</div>
{
hits.length === 0 && (
<div class="center">
<p class="font-2xl font-serif">{t("search.noResult.title")}</p>
<p>{t("search.noResult.message", { query })}</p>
<img id="no-result" src={"/img/search-no-results.webp"} />
</div>
)
}
<div id="results">
{
hits.map(({ type, data }) => {
switch (type) {
case Collections.Collectibles:
return <CollectiblePreview collectible={data} />;
case Collections.Pages:
return <PagePreview page={data} />;
case Collections.Audios:
return <AudioPreview audio={data} />;
case Collections.Videos:
return <VideoPreview video={data} />;
case Collections.Files:
return <FilePreview file={data} />;
case Collections.Images:
return <ImagePreview image={data} />;
case Collections.Recorders:
case Collections.Folders:
case Collections.ChronologyEvents:
default:
return <ErrorMessage title={`Unsupported result type: ${type}`} />;
}
})
}
</div>
<div class="center">
<Paginator
currentPage={page}
pageCount={totalPages}
getUrl={(newPage) => getSearchUrl({ ...state, page: newPage })}
/>
</div>
</AppLayout>
{/* ------------------------------------------- CSS -------------------------------------------- */}
<style>
form {
margin-top: 64px;
display: flex;
width: 100%;
max-width: 30rem;
& > * {
height: 3em;
}
& > .select-wrapper {
position: relative;
& > svg {
position: absolute;
right: 8px;
top: 50%;
translate: 0 -50%;
pointer-events: none;
touch-action: none;
}
& > select {
border-top-left-radius: 9999px;
border-bottom-left-radius: 9999px;
border: 2px solid var(--color-base-650);
border-right: unset;
padding-left: 24px;
padding-right: 32px;
background-color: var(--color-elevation-1);
appearance: none;
height: 3em;
}
}
& > input[type="search"] {
border-right: unset;
padding-inline: 1em;
padding-block: unset;
box-sizing: border-box;
flex: 1;
background-color: var(--color-elevation-0);
color: var(--color-base-800);
&:focus-visible {
outline: 3px solid var(--color-base-1000);
}
}
& > .submit {
border-top-left-radius: unset;
border-bottom-left-radius: unset;
padding-left: 0.75em;
flex-shrink: 0;
}
}
#advanced-link {
margin-bottom: 98px;
text-align: center;
}
#results {
margin-top: 5em;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: clamp(6px, 2vmin, 16px);
align-items: start;
@media (max-width: 40rem) {
grid-template-columns: 1fr 1fr;
row-gap: 12px;
}
@media (max-width: 24rem) {
grid-template-columns: 1fr;
}
}
.center {
display: grid;
flex-direction: column;
justify-items: center;
gap: 12px;
margin-block: 64px;
}
#no-result {
max-height: 70vh;
}
</style>

View File

@ -0,0 +1,115 @@
import {
Collections,
type EndpointAudio,
type EndpointChronologyEvent,
type EndpointCollectible,
type EndpointFile,
type EndpointFolder,
type EndpointImage,
type EndpointPage,
type EndpointRecorder,
type EndpointVideo,
} from "src/shared/payload/payload-sdk";
export enum Indexes {
DOCUMENT = "DOCUMENT",
}
export type MeiliDocument = {
meilid: string;
id: string;
languages: string[];
title?: string;
content?: string;
} & (
| {
type: Collections.Collectibles;
data: EndpointCollectible;
}
| {
type: Collections.Pages;
data: EndpointPage;
}
| {
type: Collections.Folders;
data: EndpointFolder;
}
| {
type: Collections.Videos;
data: EndpointVideo;
}
| {
type: Collections.Audios;
data: EndpointAudio;
}
| {
type: Collections.Images;
data: EndpointImage;
}
| {
type: Collections.Files;
data: EndpointFile;
}
| {
type: Collections.Recorders;
data: EndpointRecorder;
}
| {
type: Collections.ChronologyEvents;
data: EndpointChronologyEvent;
}
);
export type SearchResponse<T> = {
hits: T[];
query: string;
processingTimeMs: number;
hitsPerPage: number;
page: number;
totalPages: number;
totalHits: number;
facetDistribution: Record<"type" | "languages", Record<string, number>>;
};
export type SearchRequest = {
q: string;
page: number;
types?: string[] | string | undefined;
};
export class Meilisearch {
constructor(
private readonly apiURL: string,
private readonly bearer: string
) {}
async search({ q, page, types }: SearchRequest): Promise<SearchResponse<MeiliDocument>> {
const filter: string[] = [];
if (types) {
if (typeof types === "string") {
filter.push(`type = ${types}`);
} else {
filter.push(`type IN [${types.join(", ")}]`);
}
}
const body = {
q,
page,
hitsPerPage: 25,
filter,
sort: ["updatedAt:desc"]
};
const result = await fetch(`${this.apiURL}/indexes/DOCUMENT/search`, {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.bearer}`,
},
});
return await result.json();
}
}

6
src/utils/meilisearch.ts Normal file
View File

@ -0,0 +1,6 @@
import { Meilisearch } from "src/shared/meilisearch/meilisearch-sdk";
export const meilisearch = new Meilisearch(
import.meta.env.MEILISEARCH_URL,
import.meta.env.MEILISEARCH_MASTER_KEY
);