feat: add search menu to all resources
This commit is contained in:
parent
32a7f89309
commit
0e0d26c939
@ -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 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 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 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";
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { ComponentChildren } from "preact";
|
import { ComponentChildren } from "preact";
|
||||||
import { menu } from "@lib/menus.ts";
|
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 { Head } from "$fresh/runtime.ts";
|
||||||
|
import Search, { RedirectSearchHandler } from "@islands/Search.tsx";
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
@ -9,9 +10,12 @@ export type Props = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
url: URL;
|
url: URL;
|
||||||
description?: string;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
class="md:grid mx-auto"
|
class="md:grid mx-auto"
|
||||||
@ -39,11 +43,13 @@ export const MainLayout = ({ children, url, title }: Props) => {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
<RedirectSearchHandler />
|
||||||
<main
|
<main
|
||||||
class="py-5"
|
class="py-5"
|
||||||
style={{ fontFamily: "Work Sans" }}
|
style={{ fontFamily: "Work Sans" }}
|
||||||
>
|
>
|
||||||
{children}
|
{hasSearch && <Search q={url.searchParams.get("q")} {...context} />}
|
||||||
|
{!hasSearch && children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -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 $$5 from "./islands/KMenu/commands/create_article.ts";
|
||||||
import * as $$6 from "./islands/KMenu/commands/create_movie.ts";
|
import * as $$6 from "./islands/KMenu/commands/create_movie.ts";
|
||||||
import * as $$7 from "./islands/KMenu/types.ts";
|
import * as $$7 from "./islands/KMenu/types.ts";
|
||||||
|
import * as $$8 from "./islands/Search.tsx";
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
routes: {
|
routes: {
|
||||||
@ -81,6 +82,7 @@ const manifest = {
|
|||||||
"./islands/KMenu/commands/create_article.ts": $$5,
|
"./islands/KMenu/commands/create_article.ts": $$5,
|
||||||
"./islands/KMenu/commands/create_movie.ts": $$6,
|
"./islands/KMenu/commands/create_movie.ts": $$6,
|
||||||
"./islands/KMenu/types.ts": $$7,
|
"./islands/KMenu/types.ts": $$7,
|
||||||
|
"./islands/Search.tsx": $$8,
|
||||||
},
|
},
|
||||||
baseUrl: import.meta.url,
|
baseUrl: import.meta.url,
|
||||||
};
|
};
|
||||||
|
@ -102,7 +102,15 @@ export const KMenu = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEventListener("keydown", (ev: KeyboardEvent) => {
|
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;
|
visible.value = !visible.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
96
islands/Search.tsx
Normal file
96
islands/Search.tsx
Normal file
@ -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<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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 (
|
||||||
|
<div class="max-w-full">
|
||||||
|
<div class="bg-white flex items-center gap-1 w-full py-3 px-4 pr-12 rounded-xl border border-gray-300 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-600 focus:border-blue-600">
|
||||||
|
<IconSearch class="w-4 h-4" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class=""
|
||||||
|
ref={inputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onInput={handleInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isLoading ? <div>Loading...</div> : (
|
||||||
|
<div>
|
||||||
|
{data.map((d) => (
|
||||||
|
<pre class="text-white">{JSON.stringify(d.document, null, 2)}</pre>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchComponent;
|
24
lib/hooks/useDebouncedCallback.ts
Normal file
24
lib/hooks/useDebouncedCallback.ts
Normal file
@ -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;
|
||||||
|
|
55
lib/hooks/useThrottledCallback.ts
Normal file
55
lib/hooks/useThrottledCallback.ts
Normal file
@ -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<number | null>(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;
|
||||||
|
|
@ -81,6 +81,7 @@ async function initializeTypesense() {
|
|||||||
{ name: "type", type: "string", facet: true },
|
{ name: "type", type: "string", facet: true },
|
||||||
{ name: "date", type: "string", optional: true },
|
{ name: "date", type: "string", optional: true },
|
||||||
{ name: "rating", type: "int32", facet: true },
|
{ name: "rating", type: "int32", facet: true },
|
||||||
|
{ name: "tags", type: "string[]", facet: true },
|
||||||
{ name: "description", type: "string", optional: true },
|
{ name: "description", type: "string", optional: true },
|
||||||
],
|
],
|
||||||
default_sorting_field: "rating", // Default field for sorting
|
default_sorting_field: "rating", // Default field for sorting
|
||||||
@ -116,6 +117,7 @@ async function synchronizeWithTypesense() {
|
|||||||
description: sanitizeStringForTypesense(
|
description: sanitizeStringForTypesense(
|
||||||
resource?.description || resource?.content || "",
|
resource?.description || resource?.content || "",
|
||||||
),
|
),
|
||||||
|
tags: resource?.tags || [],
|
||||||
rating: resource?.meta?.rating || 0,
|
rating: resource?.meta?.rating || 0,
|
||||||
date: resource?.meta?.date?.toString() || "",
|
date: resource?.meta?.date?.toString() || "",
|
||||||
type: resource.type,
|
type: resource.type,
|
||||||
|
@ -13,6 +13,12 @@ export const handler: Handlers = {
|
|||||||
|
|
||||||
const query_by = url.searchParams.get("query_by") || "name,description";
|
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();
|
const typesenseClient = await getTypeSenseClient();
|
||||||
if (!typesenseClient) {
|
if (!typesenseClient) {
|
||||||
throw new Error("Query not available");
|
throw new Error("Query not available");
|
||||||
@ -23,7 +29,7 @@ export const handler: Handlers = {
|
|||||||
.documents().search({
|
.documents().search({
|
||||||
q: query,
|
q: query,
|
||||||
query_by,
|
query_by,
|
||||||
limit_hits: 100,
|
filter_by,
|
||||||
});
|
});
|
||||||
|
|
||||||
return json(searchResults.hits);
|
return json(searchResults.hits);
|
||||||
|
@ -15,7 +15,7 @@ export const handler: Handlers<Article[] | null> = {
|
|||||||
|
|
||||||
export default function Greet(props: PageProps<Article[] | null>) {
|
export default function Greet(props: PageProps<Article[] | null>) {
|
||||||
return (
|
return (
|
||||||
<MainLayout url={props.url} title="Articles">
|
<MainLayout url={props.url} title="Articles" context={{ type: "article" }}>
|
||||||
<header class="flex gap-4 items-center mb-5 md:hidden">
|
<header class="flex gap-4 items-center mb-5 md:hidden">
|
||||||
<a
|
<a
|
||||||
class="px-4 ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg flex items-center gap-1"
|
class="px-4 ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg flex items-center gap-1"
|
||||||
|
@ -15,7 +15,7 @@ export const handler: Handlers<Movie[] | null> = {
|
|||||||
|
|
||||||
export default function Greet(props: PageProps<Movie[] | null>) {
|
export default function Greet(props: PageProps<Movie[] | null>) {
|
||||||
return (
|
return (
|
||||||
<MainLayout url={props.url} title="Movies">
|
<MainLayout url={props.url} title="Movies" context={{ type: "movie" }}>
|
||||||
<KMenu type="main" context={false} />
|
<KMenu type="main" context={false} />
|
||||||
<header class="flex gap-4 items-center mb-5 md:hidden">
|
<header class="flex gap-4 items-center mb-5 md:hidden">
|
||||||
<a
|
<a
|
||||||
|
@ -14,7 +14,7 @@ export const handler: Handlers<Recipe[] | null> = {
|
|||||||
|
|
||||||
export default function Greet(props: PageProps<Recipe[] | null>) {
|
export default function Greet(props: PageProps<Recipe[] | null>) {
|
||||||
return (
|
return (
|
||||||
<MainLayout url={props.url} title="Recipes">
|
<MainLayout url={props.url} title="Recipes" context={{ type: "recipe" }}>
|
||||||
<header class="flex gap-4 items-center mb-2 lg:mb-5 md:hidden">
|
<header class="flex gap-4 items-center mb-2 lg:mb-5 md:hidden">
|
||||||
<a
|
<a
|
||||||
class="px-4 lg:ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg flex items-center gap-1"
|
class="px-4 lg:ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg flex items-center gap-1"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user