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 (
-
+