diff --git a/components/icons.tsx b/components/icons.tsx index 6424c81..49587ac 100644 --- a/components/icons.tsx +++ b/components/icons.tsx @@ -13,3 +13,4 @@ export { default as IconCircleMinus } from "https://deno.land/x/tabler_icons_tsx export { default as IconLoader2 } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/loader-2.tsx"; export { default as IconLogin } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/login.tsx"; export { default as IconLogout } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/logout.tsx"; +export { default as IconSearch } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/search.tsx"; diff --git a/components/layouts/main.tsx b/components/layouts/main.tsx index 0877f17..379178f 100644 --- a/components/layouts/main.tsx +++ b/components/layouts/main.tsx @@ -1,7 +1,8 @@ import { ComponentChildren } from "preact"; import { menu } from "@lib/menus.ts"; -import { CSS, KATEX_CSS, render } from "https://deno.land/x/gfm/mod.ts"; +import { CSS, KATEX_CSS } from "https://deno.land/x/gfm@0.2.5/mod.ts"; import { Head } from "$fresh/runtime.ts"; +import Search, { RedirectSearchHandler } from "@islands/Search.tsx"; export type Props = { children: ComponentChildren; @@ -9,9 +10,12 @@ export type Props = { name?: string; url: URL; description?: string; + context?: { type: string }; }; -export const MainLayout = ({ children, url, title }: Props) => { +export const MainLayout = ({ children, url, title, context }: Props) => { + const hasSearch = url.search.includes("q="); + return (
{ })} +
- {children} + {hasSearch && } + {!hasSearch && children}
); diff --git a/fresh.gen.ts b/fresh.gen.ts index 3c8df59..8984251 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -39,6 +39,7 @@ import * as $$4 from "./islands/KMenu/commands/add_movie_infos.ts"; import * as $$5 from "./islands/KMenu/commands/create_article.ts"; import * as $$6 from "./islands/KMenu/commands/create_movie.ts"; import * as $$7 from "./islands/KMenu/types.ts"; +import * as $$8 from "./islands/Search.tsx"; const manifest = { routes: { @@ -81,6 +82,7 @@ const manifest = { "./islands/KMenu/commands/create_article.ts": $$5, "./islands/KMenu/commands/create_movie.ts": $$6, "./islands/KMenu/types.ts": $$7, + "./islands/Search.tsx": $$8, }, baseUrl: import.meta.url, }; diff --git a/islands/KMenu.tsx b/islands/KMenu.tsx index c131a54..792e0fd 100644 --- a/islands/KMenu.tsx +++ b/islands/KMenu.tsx @@ -102,7 +102,15 @@ export const KMenu = ( } useEventListener("keydown", (ev: KeyboardEvent) => { - if (ev.key === "/" && ev.ctrlKey) { + if (ev.key === "k") { + if (ev?.target?.nodeName == "INPUT") { + return; + } + if (!visible.value) { + ev.preventDefault(); + ev.stopPropagation(); + } + visible.value = !visible.value; } diff --git a/islands/Search.tsx b/islands/Search.tsx new file mode 100644 index 0000000..c27390a --- /dev/null +++ b/islands/Search.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import useDebouncedCallback from "@lib/hooks/useDebouncedCallback.ts"; +import { IconSearch } from "@components/icons.tsx"; +import { useEventListener } from "@lib/hooks/useEventListener.ts"; + +export const RedirectSearchHandler = () => { + useEventListener("keydown", (e: KeyboardEvent) => { + if (e?.target?.nodeName == "INPUT") return; + + if ( + e.key === "?" && + window.location.search === "" + ) { + window.location.href += "?q="; + } + }); + + return <>; +}; + +const SearchComponent = ( + { q, type }: { q: string; type?: string }, +) => { + const [searchQuery, setSearchQuery] = useState(q); + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(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); + window.history.replaceState({}, "", u); + } + } + + const fetchData = async (query: string) => { + try { + setIsLoading(true); + const fetchUrl = new URL(window.location.href); + fetchUrl.pathname = "/api/resources"; + if (searchQuery) { + fetchUrl.searchParams.set("q", encodeURIComponent(searchQuery)); + } else { + return; + } + if (type) { + fetchUrl.searchParams.set("type", type); + } + const response = await fetch(fetchUrl); + const jsonData = await response.json(); + setData(jsonData); + setIsLoading(false); + } catch (error) { + console.error("Error fetching data:", error); + setIsLoading(false); + } + }; + + useEffect(() => { + if (inputRef.current && searchQuery?.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; + setSearchQuery(target.value); + debouncedFetchData(target.value); // Call the debounced fetch function with the updated search query + }; + + return ( +
+
+ + +
+ {isLoading ?
Loading...
: ( +
+ {data.map((d) => ( +
{JSON.stringify(d.document, null, 2)}
+ ))} +
+ )} +
+ ); +}; + +export default SearchComponent; diff --git a/lib/hooks/useDebouncedCallback.ts b/lib/hooks/useDebouncedCallback.ts new file mode 100644 index 0000000..7a0c05f --- /dev/null +++ b/lib/hooks/useDebouncedCallback.ts @@ -0,0 +1,24 @@ +// useDebouncedCallback.tsx +import { useEffect, useState } from "preact/hooks"; + +const useDebouncedCallback = ( + callback: (...args: any[]) => void, + delay: number, +) => { + const [debouncedCallback, setDebouncedCallback] = useState(() => callback); + + useEffect(() => { + const debounceHandler = setTimeout(() => { + setDebouncedCallback(() => callback); + }, delay); + + return () => { + clearTimeout(debounceHandler); + }; + }, [callback, delay]); + + return debouncedCallback; +}; + +export default useDebouncedCallback; + diff --git a/lib/hooks/useThrottledCallback.ts b/lib/hooks/useThrottledCallback.ts new file mode 100644 index 0000000..fd8d29f --- /dev/null +++ b/lib/hooks/useThrottledCallback.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from "preact/hooks"; + +type ThrottleOptions = { + leading?: boolean; + trailing?: boolean; +}; + +const useThrottledCallback = ( + callback: (...args: any[]) => void, + delay: number, + options: ThrottleOptions = {}, +) => { + const { leading = true, trailing = true } = options; + + const [lastExecTime, setLastExecTime] = useState(0); + const [timer, setTimer] = useState(null); + const [isLeading, setIsLeading] = useState(false); + + useEffect(() => { + return () => { + if (timer) { + clearTimeout(timer); + } + }; + }, [timer]); + + const throttledCallback = (...args: any[]) => { + const now = Date.now(); + + if (leading && !isLeading) { + callback(...args); + setLastExecTime(now); + setIsLeading(true); + } + + if (trailing) { + if (timer) { + clearTimeout(timer); + } + + setTimer(setTimeout(() => { + if (now - lastExecTime >= delay) { + callback(...args); + setLastExecTime(now); + } + setIsLeading(false); + }, delay)); + } + }; + + return throttledCallback; +}; + +export default useThrottledCallback; + diff --git a/lib/typesense.ts b/lib/typesense.ts index d97e352..e2f5071 100644 --- a/lib/typesense.ts +++ b/lib/typesense.ts @@ -81,6 +81,7 @@ async function initializeTypesense() { { name: "type", type: "string", facet: true }, { name: "date", type: "string", optional: true }, { name: "rating", type: "int32", facet: true }, + { name: "tags", type: "string[]", facet: true }, { name: "description", type: "string", optional: true }, ], default_sorting_field: "rating", // Default field for sorting @@ -116,6 +117,7 @@ async function synchronizeWithTypesense() { description: sanitizeStringForTypesense( resource?.description || resource?.content || "", ), + tags: resource?.tags || [], rating: resource?.meta?.rating || 0, date: resource?.meta?.date?.toString() || "", type: resource.type, diff --git a/routes/api/resources.ts b/routes/api/resources.ts index 5dc79aa..0c87e28 100644 --- a/routes/api/resources.ts +++ b/routes/api/resources.ts @@ -13,6 +13,12 @@ export const handler: Handlers = { const query_by = url.searchParams.get("query_by") || "name,description"; + let filter_by = ""; + const type = url.searchParams.get("type"); + if (type) { + filter_by = `type:=${type}`; + } + const typesenseClient = await getTypeSenseClient(); if (!typesenseClient) { throw new Error("Query not available"); @@ -23,7 +29,7 @@ export const handler: Handlers = { .documents().search({ q: query, query_by, - limit_hits: 100, + filter_by, }); return json(searchResults.hits); diff --git a/routes/articles/index.tsx b/routes/articles/index.tsx index cb1a151..8dcdf65 100644 --- a/routes/articles/index.tsx +++ b/routes/articles/index.tsx @@ -15,7 +15,7 @@ export const handler: Handlers = { export default function Greet(props: PageProps) { return ( - +
= { export default function Greet(props: PageProps) { return ( - +
= { export default function Greet(props: PageProps) { return ( - +