diff --git a/components/layouts/main.tsx b/components/layouts/main.tsx index da39490..75cf5e0 100644 --- a/components/layouts/main.tsx +++ b/components/layouts/main.tsx @@ -5,6 +5,7 @@ import { Head } from "$fresh/runtime.ts"; import Search, { RedirectSearchHandler } from "@islands/Search.tsx"; import { KMenu } from "@islands/KMenu.tsx"; import { Emoji } from "@components/Emoji.tsx"; +import { SearchResult } from "@lib/types.ts"; export type Props = { children: ComponentChildren; @@ -13,9 +14,12 @@ export type Props = { url: URL; description?: string; context?: { type: string }; + searchResults?: SearchResult; }; -export const MainLayout = ({ children, url, title, context }: Props) => { +export const MainLayout = ( + { children, url, title, context, searchResults }: Props, +) => { const hasSearch = url.search.includes("q="); return ( @@ -49,7 +53,13 @@ export const MainLayout = ({ children, url, title, context }: Props) => { class="py-5" style={{ fontFamily: "Work Sans" }} > - {hasSearch && } + {hasSearch && ( + + )} {!hasSearch && children} diff --git a/islands/Search.tsx b/islands/Search.tsx index 97d01e6..f32f5c9 100644 --- a/islands/Search.tsx +++ b/islands/Search.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "preact/hooks"; +import { useEffect, useRef } from "preact/hooks"; import useDebouncedCallback from "@lib/hooks/useDebouncedCallback.ts"; -import { IconGhost, IconLoader2, IconSearch } from "@components/icons.tsx"; +import { IconLoader2, IconSearch } from "@components/icons.tsx"; import { useEventListener } from "@lib/hooks/useEventListener.ts"; import { SearchResult } from "@lib/types.ts"; import { resources } from "@lib/resources.ts"; @@ -12,6 +12,31 @@ import { useSignal } from "@preact/signals"; import Image from "@components/Image.tsx"; import { Emoji } from "@components/Emoji.tsx"; +export async function fetchQueryResource(url: URL, type = "") { + const query = url.searchParams.get("q"); + const status = url.searchParams.get("status"); + + try { + url.pathname = "/api/resources"; + if (query) { + url.searchParams.set("q", encodeURIComponent(query)); + } else { + return; + } + if (status) { + url.searchParams.set("status", "not-seen"); + } + if (type) { + url.searchParams.set("type", type); + } + const response = await fetch(url); + const jsonData = await response.json(); + return jsonData; + } catch (error) { + console.error("Error fetching data:", error); + } +} + export const RedirectSearchHandler = () => { if (getCookie("session_cookie")) { useEventListener("keydown", (e: KeyboardEvent) => { @@ -84,17 +109,22 @@ export const SearchResultList = ( }; const Search = ( - { q = "*", type }: { q: string; type?: string }, + { q = "*", type, results }: { + q: string; + type?: string; + results?: SearchResult; + }, ) => { - const [searchQuery, setSearchQuery] = useState(q); - const [data, setData] = useState(); - const [isLoading, setIsLoading] = useState(false); + const searchQuery = useSignal(q); + const data = useSignal(results); + const isLoading = useSignal(false); const showSeenStatus = useSignal(false); const inputRef = useRef(null); + if ("history" in globalThis) { const u = new URL(window.location.href); - if (u.searchParams.get("q") !== searchQuery) { - u.searchParams.set("q", searchQuery); + if (u.searchParams.get("q") !== searchQuery.value) { + u.searchParams.set("q", searchQuery.value); } if (showSeenStatus.value) { u.searchParams.set("status", "not-seen"); @@ -105,53 +135,41 @@ const Search = ( window.history.replaceState({}, "", u); } - const fetchData = async (query: string, showSeen: boolean) => { + const fetchData = async () => { try { - setIsLoading(true); - const fetchUrl = new URL(window.location.href); - fetchUrl.pathname = "/api/resources"; - if (query) { - fetchUrl.searchParams.set("q", encodeURIComponent(query)); - } else { - return; - } - if (showSeen) { - fetchUrl.searchParams.set("status", "not-seen"); - } - if (type) { - fetchUrl.searchParams.set("type", type); - } - const response = await fetch(fetchUrl); - const jsonData = await response.json(); - setData(jsonData); - setIsLoading(false); + isLoading.value = true; + const jsonData = await fetchQueryResource( + new URL(window?.location.href), + type, + ); + data.value = jsonData; + isLoading.value = false; } catch (error) { console.error("Error fetching data:", error); - setIsLoading(false); + isLoading.value = false; } }; + const debouncedFetchData = useDebouncedCallback(fetchData, 500); // Debounce the fetchData function with a delay of 500ms useEffect(() => { - if (inputRef.current && searchQuery?.length === 0) { + if (inputRef.current && searchQuery?.value.length === 0) { inputRef.current?.focus(); } }, [inputRef.current, searchQuery]); - const debouncedFetchData = useDebouncedCallback(fetchData, 500); // Debounce the fetchData function with a delay of 500ms - const handleInputChange = (event: Event) => { const target = event.target as HTMLInputElement; - if (target.value !== searchQuery) { - setSearchQuery(target.value); + if (target.value !== searchQuery.value) { + searchQuery.value = target.value; } }; useEffect(() => { - debouncedFetchData(searchQuery, showSeenStatus.value); // Call the debounced fetch function with the updated search query - }, [searchQuery, showSeenStatus.value]); + debouncedFetchData(); // Call the debounced fetch function with the updated search query + }, [searchQuery.value, showSeenStatus.value]); useEffect(() => { - debouncedFetchData(q, showSeenStatus.value); + debouncedFetchData(); }, []); return ( @@ -161,7 +179,7 @@ const Search = ( class="flex items-center gap-1 rounded-xl w-full shadow-2xl" style={{ background: "#2B2930", color: "#818181" }} > - {isLoading && searchQuery + {isLoading.value && searchQuery.value ? : } - {data?.hits?.length && !isLoading - ? - : isLoading + {data?.value?.hits?.length && !isLoading.value + ? + : isLoading.value ?
: (
`\`${t}\``).join(",")}]`); + for (const tag of tags) { + q = q.replaceAll(`#${tag}`, ""); + } + } + + return await typesenseClient.collections("resources") + .documents().search({ + q, + query_by, + facet_by: "rating,author,tags", + max_facet_values: 10, + filter_by: filter_by.join(" && "), + per_page: 50, + }); +} diff --git a/lib/types.ts b/lib/types.ts index 5157a90..c503388 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -54,4 +54,9 @@ export type TypesenseDocument = { image?: string; }; +export enum ResourceStatus { + COMPLETED = "completed", + NOT_COMPLETED = "not_completed", +} + export type SearchResult = SearchResponse; diff --git a/routes/api/resources.ts b/routes/api/resources.ts index 20a815c..4d3cf1c 100644 --- a/routes/api/resources.ts +++ b/routes/api/resources.ts @@ -2,7 +2,7 @@ import { Handlers } from "$fresh/server.ts"; import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; import { getTypeSenseClient } from "@lib/typesense.ts"; import { json } from "@lib/helpers.ts"; -import { extractHashTags } from "@lib/string.ts"; +import { parseResourceUrl, searchResource } from "@lib/search.ts"; export const handler: Handlers = { async GET(req, ctx) { @@ -11,52 +11,10 @@ export const handler: Handlers = { throw new AccessDeniedError(); } - const url = new URL(req.url); - let query = url.searchParams.get("q"); - if (!query) { - throw new BadRequestError('Query parameter "q" is required.'); - } - query = decodeURIComponent(query); - - const query_by = url.searchParams.get("query_by") || - "name,description,author,tags"; - - const filter_by: string[] = []; - const type = url.searchParams.get("type"); - if (type) { - filter_by.push(`type:=${type}`); - } - - const status = url.searchParams.get("status"); - if (status) { - filter_by.push(`status:=${status}`); - } - - const hashTags = extractHashTags(query); - if (hashTags?.length) { - filter_by.push(`tags:[${hashTags.map((t) => `\`${t}\``).join(",")}]`); - for (const tag of hashTags) { - query = query.replaceAll(`#${tag}`, ""); - } - } - - const typesenseClient = await getTypeSenseClient(); - if (!typesenseClient) { - throw new Error("Query not available"); - } - - console.log({ query, query_by, filter_by: filter_by.join(" && ") }); + const searchParams = parseResourceUrl(req.url); // Perform the Typesense search - const searchResults = await typesenseClient.collections("resources") - .documents().search({ - q: query, - query_by, - facet_by: "rating,author,tags", - max_facet_values: 10, - filter_by: filter_by.join(" && "), - per_page: 50, - }); + const searchResults = await searchResource(searchParams); return json(searchResults); }, diff --git a/routes/articles/index.tsx b/routes/articles/index.tsx index 1cef702..b69361f 100644 --- a/routes/articles/index.tsx +++ b/routes/articles/index.tsx @@ -6,17 +6,30 @@ import { KMenu } from "@islands/KMenu.tsx"; import { Grid } from "@components/Grid.tsx"; import { IconArrowLeft } from "@components/icons.tsx"; import { RedirectSearchHandler } from "@islands/Search.tsx"; +import { parseResourceUrl, searchResource } from "@lib/search.ts"; +import { SearchResult } from "@lib/types.ts"; export const handler: Handlers = { - async GET(_, ctx) { - const movies = await getAllArticles(); - return ctx.render(movies); + async GET(req, ctx) { + const articles = await getAllArticles(); + const searchParams = parseResourceUrl(req.url); + const searchResults = searchParams && + await searchResource({ ...searchParams, type: "article" }); + return ctx.render({ articles, searchResults }); }, }; -export default function Greet(props: PageProps) { +export default function Greet( + props: PageProps<{ articles: Article[] | null; searchResults: SearchResult }>, +) { + const { articles, searchResults } = props.data; return ( - +
) { - {props.data?.map((doc) => { + {articles?.map((doc) => { return ( = { - async GET(_, ctx) { + async GET(req, ctx) { const movies = await getAllMovies(); - return ctx.render(movies); + const searchParams = parseResourceUrl(req.url); + const searchResults = searchParams && + await searchResource({ ...searchParams, type: "movie" }); + return ctx.render({ movies, searchResults }); }, }; -export default function Greet(props: PageProps) { +export default function Greet( + props: PageProps<{ movies: Movie[] | null; searchResults: SearchResult }>, +) { + const { movies, searchResults } = props.data; + return ( - +
@@ -31,7 +45,7 @@ export default function Greet(props: PageProps) {

🍿 Movies

- {props.data?.map((doc) => { + {movies?.map((doc) => { return ; })} diff --git a/routes/recipes/index.tsx b/routes/recipes/index.tsx index 29dbc71..babe372 100644 --- a/routes/recipes/index.tsx +++ b/routes/recipes/index.tsx @@ -5,18 +5,31 @@ import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts"; import { Grid } from "@components/Grid.tsx"; import { IconArrowLeft } from "@components/icons.tsx"; import { KMenu } from "@islands/KMenu.tsx"; -import { RedirectSearchHandler } from "@islands/Search.tsx"; +import { fetchQueryResource, RedirectSearchHandler } from "@islands/Search.tsx"; +import { parseResourceUrl, searchResource } from "@lib/search.ts"; +import { SearchResult } from "@lib/types.ts"; export const handler: Handlers = { - async GET(_, ctx) { + async GET(req, ctx) { const recipes = await getAllRecipes(); - return ctx.render(recipes); + const searchParams = parseResourceUrl(req.url); + const searchResults = searchParams && + await searchResource({ ...searchParams, type: "recipe" }); + return ctx.render({ recipes, searchResults }); }, }; -export default function Greet(props: PageProps) { +export default function Greet( + props: PageProps<{ recipes: Recipe[] | null; searchResults: SearchResult }>, +) { + const { recipes, searchResults } = props.data; return ( - +
@@ -31,7 +44,7 @@ export default function Greet(props: PageProps) {

🍽️ Recipes

- {props.data?.map((doc) => { + {recipes?.map((doc) => { return ; })} diff --git a/routes/series/index.tsx b/routes/series/index.tsx index 0789727..ef812b9 100644 --- a/routes/series/index.tsx +++ b/routes/series/index.tsx @@ -6,17 +6,31 @@ import { getAllSeries, Series } from "@lib/resource/series.ts"; import { RedirectSearchHandler } from "@islands/Search.tsx"; import { KMenu } from "@islands/KMenu.tsx"; import { MovieCard } from "@components/MovieCard.tsx"; +import { parseResourceUrl, searchResource } from "@lib/search.ts"; +import { SearchResult } from "@lib/types.ts"; export const handler: Handlers = { - async GET(_, ctx) { - const movies = await getAllSeries(); - return ctx.render(movies); + async GET(req, ctx) { + const series = await getAllSeries(); + const searchParams = parseResourceUrl(req.url); + const searchResults = searchParams && + await searchResource({ ...searchParams, type: "series" }); + return ctx.render({ series, searchResults }); }, }; -export default function Greet(props: PageProps) { +export default function Greet( + props: PageProps<{ series: Series[] | null; searchResults: SearchResult }>, +) { + const { series, searchResults } = props.data; + return ( - +
@@ -31,7 +45,7 @@ export default function Greet(props: PageProps) {

🎥 Series

- {props.data?.map((doc) => { + {series?.map((doc) => { return ; })}