208 lines
		
	
	
		
			5.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			208 lines
		
	
	
		
			5.8 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { useEffect, useRef } from "preact/hooks";
 | |
| import useDebouncedCallback from "@lib/hooks/useDebouncedCallback.ts";
 | |
| import { IconLoader2, IconSearch } from "@components/icons.tsx";
 | |
| import { useEventListener } from "@lib/hooks/useEventListener.ts";
 | |
| import { GenericResource } from "@lib/types.ts";
 | |
| import { resources } from "@lib/resources.ts";
 | |
| import { getCookie } from "@lib/string.ts";
 | |
| import { IS_BROWSER } from "$fresh/runtime.ts";
 | |
| import Checkbox from "@components/Checkbox.tsx";
 | |
| import { Rating } from "@components/Rating.tsx";
 | |
| 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/query";
 | |
|     url.searchParams.set("q", encodeURIComponent(query || ""));
 | |
|     if (status) {
 | |
|       url.searchParams.set("status", "not-seen");
 | |
|     }
 | |
|     if (type) {
 | |
|       url.searchParams.set("types", 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) => {
 | |
|       if (e?.target?.nodeName == "INPUT") return;
 | |
|       if (
 | |
|         e.key === "?" &&
 | |
|         globalThis.location.search === ""
 | |
|       ) {
 | |
|         globalThis.location.href += "?q=";
 | |
|       }
 | |
|     }, IS_BROWSER ? document?.body : undefined);
 | |
|   }
 | |
| 
 | |
|   return <></>;
 | |
| };
 | |
| 
 | |
| const SearchResultImage = ({ src }: { src: string }) => {
 | |
|   return (
 | |
|     <Image
 | |
|       class="object-cover w-12 h-12 rounded-full"
 | |
|       width="50"
 | |
|       height="50"
 | |
|       src={src}
 | |
|       alt="preview image"
 | |
|     />
 | |
|   );
 | |
| };
 | |
| 
 | |
| export const SearchResultItem = (
 | |
|   { item, showEmoji = false }: {
 | |
|     item: GenericResource;
 | |
|     showEmoji?: boolean;
 | |
|   },
 | |
| ) => {
 | |
|   const resourceType = resources[item.type];
 | |
|   const href = resourceType ? `${resourceType.link}/${item.id}` : "";
 | |
|   return (
 | |
|     <a
 | |
|       href={href}
 | |
|       class="p-2 text-white flex gap-4 items-center rounded-2xl hover:bg-gray-700"
 | |
|     >
 | |
|       {showEmoji && resourceType
 | |
|         ? <Emoji class="w-7 h-7" name={resourceType.emoji} />
 | |
|         : ""}
 | |
|       {item.meta?.image && <SearchResultImage src={item.meta?.image} />}
 | |
|       {item?.name}
 | |
|     </a>
 | |
|   );
 | |
| };
 | |
| 
 | |
| export const SearchResultList = (
 | |
|   { result, showEmoji }: { result: GenericResource[]; showEmoji?: boolean },
 | |
| ) => {
 | |
|   return (
 | |
|     <div class="mt-4">
 | |
|       {result?.length
 | |
|         ? (
 | |
|           <div class="flex flex-col gap-4">
 | |
|             {result.map((hit) => (
 | |
|               <SearchResultItem item={hit} showEmoji={showEmoji} />
 | |
|             ))}
 | |
|           </div>
 | |
|         )
 | |
|         : <div style={{ color: "#818181" }}>No Results</div>}
 | |
|     </div>
 | |
|   );
 | |
| };
 | |
| 
 | |
| const Search = (
 | |
|   { q = "*", type, results }: {
 | |
|     q: string;
 | |
|     type?: string;
 | |
|     results?: GenericResource[];
 | |
|   },
 | |
| ) => {
 | |
|   const searchQuery = useSignal(q);
 | |
|   const data = useSignal<GenericResource[] | undefined>(results);
 | |
|   const isLoading = useSignal(false);
 | |
|   const showSeenStatus = useSignal(false);
 | |
|   const inputRef = useRef<HTMLInputElement>(null);
 | |
| 
 | |
|   if ("history" in globalThis) {
 | |
|     const u = new URL(globalThis.location.href);
 | |
|     if (u.searchParams.get("q") !== searchQuery.value) {
 | |
|       u.searchParams.set("q", searchQuery.value);
 | |
|     }
 | |
|     if (showSeenStatus.value) {
 | |
|       u.searchParams.set("rating", "0");
 | |
|     } else {
 | |
|       u.searchParams.delete("rating");
 | |
|     }
 | |
| 
 | |
|     globalThis.history.replaceState({}, "", u);
 | |
|   }
 | |
| 
 | |
|   const fetchData = async () => {
 | |
|     try {
 | |
|       isLoading.value = true;
 | |
|       const jsonData = await fetchQueryResource(
 | |
|         new URL(globalThis?.location.href),
 | |
|         type,
 | |
|       );
 | |
|       data.value = jsonData;
 | |
|       isLoading.value = false;
 | |
|     } catch (error) {
 | |
|       console.error("Error fetching data:", error);
 | |
|       isLoading.value = false;
 | |
|     }
 | |
|   };
 | |
|   const debouncedFetchData = useDebouncedCallback(fetchData, 500); // Debounce the fetchData function with a delay of 500ms
 | |
| 
 | |
|   useEffect(() => {
 | |
|     if (inputRef.current && searchQuery?.value.length === 0) {
 | |
|       inputRef.current?.focus();
 | |
|     }
 | |
|   }, [inputRef.current, searchQuery]);
 | |
| 
 | |
|   const handleInputChange = (event: Event) => {
 | |
|     const target = event.target as HTMLInputElement;
 | |
|     if (target.value !== searchQuery.value) {
 | |
|       searchQuery.value = target.value;
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   useEffect(() => {
 | |
|     debouncedFetchData(); // Call the debounced fetch function with the updated search query
 | |
|   }, [searchQuery.value, showSeenStatus.value]);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     debouncedFetchData();
 | |
|   }, []);
 | |
| 
 | |
|   return (
 | |
|     <div class="mt-2">
 | |
|       <header class="flex items-center gap-4 items-52">
 | |
|         <div
 | |
|           class="flex items-center gap-1 rounded-2xl w-full shadow-2xl"
 | |
|           style={{ background: "#2B2930", color: "#818181" }}
 | |
|         >
 | |
|           {isLoading.value && searchQuery.value
 | |
|             ? <IconLoader2 class="w-4 h-4 ml-4 mr-2 animate-spin" />
 | |
|             : <IconSearch class="w-4 h-4 ml-4 mr-2" />}
 | |
|           <input
 | |
|             type="text"
 | |
|             style={{ fontSize: "1.2em" }}
 | |
|             class="bg-transparent py-3 w-full"
 | |
|             ref={inputRef}
 | |
|             value={searchQuery.value}
 | |
|             onInput={handleInputChange}
 | |
|           />
 | |
|         </div>
 | |
|         <Checkbox label="seen" checked={showSeenStatus} />
 | |
|         <Rating rating={4} />
 | |
|       </header>
 | |
|       {data.value?.length && !isLoading.value
 | |
|         ? <SearchResultList showEmoji={!type} result={data.value} />
 | |
|         : isLoading.value
 | |
|         ? <div />
 | |
|         : (
 | |
|           <div
 | |
|             class="flex items-center gap-2 p-2 my-4 mx-3"
 | |
|             style={{ color: "#818181" }}
 | |
|           >
 | |
|             <Emoji class="w-8 h-8" name="Ghost.png" />
 | |
|             No Results
 | |
|           </div>
 | |
|         )}
 | |
|     </div>
 | |
|   );
 | |
| };
 | |
| 
 | |
| export default Search;
 |