feat: enhance layout of search

This commit is contained in:
max_richter 2023-08-06 17:47:26 +02:00
parent 0e0d26c939
commit 6f650b568d
25 changed files with 518 additions and 172 deletions

View File

@ -1,10 +1,11 @@
import { Card } from "@components/Card.tsx";
import { Recipe } from "@lib/resource/recipes.ts";
import { isLocalImage } from "@lib/string.ts";
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const { meta: { image = "/placeholder.svg" } = {} } = recipe;
const imageUrl = image.startsWith("Recipes/images/")
const imageUrl = isLocalImage(image)
? `/api/images?image=${image}&width=200&height=200`
: image;

View File

@ -4,6 +4,7 @@ import {
IconEdit,
IconExternalLink,
} from "@components/icons.tsx";
import { isLocalImage } from "@lib/string.ts";
export function RecipeHero(
{ data, subline, backlink, editLink }: {
@ -15,10 +16,9 @@ export function RecipeHero(
) {
const { meta: { image } = {} } = data;
const imageUrl =
(image?.startsWith("Recipes/images/") || image?.startsWith("Media/movies/"))
? `/api/images?image=${image}&width=800`
: image;
const imageUrl = (image && isLocalImage(image))
? `/api/images?image=${image}&width=800`
: image;
return (
<div

View File

@ -14,3 +14,4 @@ export { default as IconLoader2 } from "https://deno.land/x/tabler_icons_tsx@0.0
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";
export { default as IconGhost } from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/ghost.tsx";

View File

@ -1,5 +1,5 @@
import { ComponentChildren } from "preact";
import { menu } from "@lib/menus.ts";
import { resources } from "@lib/resources.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";
@ -29,7 +29,7 @@ export const MainLayout = ({ children, url, title, context }: Props) => {
</Head>
<aside class="p-4 hidden md:block">
<nav class="min-h-fit rounded-3xl p-3 grid gap-3 fixed t-0">
{menu.map((m) => {
{Object.values(resources).map((m) => {
return (
<a
href={m.link}
@ -37,7 +37,7 @@ export const MainLayout = ({ children, url, title, context }: Props) => {
m.link === url.pathname ? "bg-white text-black" : "text-white"
} p-3 text-xl w-full rounded-2xl`}
>
{m.name}
&nbsp;{m.emoji} {m.name}
</a>
);
})}

View File

@ -15,6 +15,7 @@
}
},
"imports": {
"typesense": "https://raw.githubusercontent.com/bradenmacdonald/typesense-deno/main/mod.ts",
"$fresh/": "https://deno.land/x/fresh@1.3.1/",
"yaml": "https://deno.land/std@0.197.0/yaml/mod.ts",
"preact": "https://esm.sh/preact@10.15.1",
@ -37,4 +38,4 @@
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
}

View File

@ -34,6 +34,16 @@ export const menus: Record<string, Menu> = {
return !getCookie("session_cookie");
},
},
{
title: "Search",
icon: "IconSearch",
cb: () => {
window.location.href += "?q=";
},
visible: () => {
return !!getCookie("session_cookie") && window.location.search === "";
},
},
{
title: "Logout",
icon: "IconLogout",

View File

@ -45,6 +45,6 @@ export const addMovieInfos: MenuEntry = {
visible: () => {
const loc = globalThis["location"];
if (!getCookie("session_cookie")) return false;
return loc?.pathname?.includes("movie");
return loc?.pathname?.includes("movie") && !loc.pathname.endsWith("movies");
},
};

View File

@ -1,7 +1,15 @@
import { useEffect, useRef, useState } from "preact/hooks";
import useDebouncedCallback from "@lib/hooks/useDebouncedCallback.ts";
import { IconSearch } from "@components/icons.tsx";
import { IconGhost, IconLoader2, IconSearch } from "@components/icons.tsx";
import { useEventListener } from "@lib/hooks/useEventListener.ts";
import { SearchResponse } from "https://raw.githubusercontent.com/bradenmacdonald/typesense-deno/main/src/Typesense/Documents.ts";
import { Recipe } from "@lib/resource/recipes.ts";
import { Movie } from "@lib/resource/movies.ts";
import { Article } from "@lib/resource/articles.ts";
import { useTraceUpdate } from "@lib/hooks/useTraceProps.ts";
import { SearchResult } from "@lib/types.ts";
import { resources } from "@lib/resources.ts";
import { isLocalImage } from "@lib/string.ts";
export const RedirectSearchHandler = () => {
useEventListener("keydown", (e: KeyboardEvent) => {
@ -18,11 +26,63 @@ export const RedirectSearchHandler = () => {
return <></>;
};
const SearchResultImage = ({ src }: { src: string }) => {
const imageSrc = isLocalImage(src)
? `/api/images?image=${src}&width=50&height=50`
: src;
return (
<img
class="object-cover w-12 h-12 rounded-full"
src={imageSrc}
alt="preview image"
/>
);
};
export const SearchResultItem = (
{ item, showEmoji = false }: {
item: NonNullable<SearchResult["hits"]>[number];
showEmoji?: boolean;
},
) => {
const doc = item.document;
const resourceType = resources[doc.type];
const href = (resourceType) ? `${resourceType.link}/${doc.id}` : "";
return (
<a
href={href}
class="p-2 text-white flex gap-4 items-center rounded-2xl hover:bg-gray-700"
>
{doc?.image && <SearchResultImage src={doc.image} />}
{`${showEmoji && resourceType ? resourceType.emoji : ""}`} {doc?.name}
</a>
);
};
export const SearchResultList = (
{ result, showEmoji }: { result: SearchResult; showEmoji?: boolean },
) => {
return (
<div class="mt-4">
{result?.hits
? (
<div class="flex flex-col gap-4">
{result.hits.map((hit) => (
<SearchResultItem item={hit} showEmoji={showEmoji} />
))}
</div>
)
: <div style={{ color: "#818181" }}>No Results</div>}
</div>
);
};
const SearchComponent = (
{ q, type }: { q: string; type?: string },
) => {
const [searchQuery, setSearchQuery] = useState(q);
const [data, setData] = useState<any[]>([]);
const [data, setData] = useState<SearchResult>();
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
if ("history" in globalThis) {
@ -38,8 +98,8 @@ const SearchComponent = (
setIsLoading(true);
const fetchUrl = new URL(window.location.href);
fetchUrl.pathname = "/api/resources";
if (searchQuery) {
fetchUrl.searchParams.set("q", encodeURIComponent(searchQuery));
if (query) {
fetchUrl.searchParams.set("q", encodeURIComponent(query));
} else {
return;
}
@ -66,29 +126,48 @@ const SearchComponent = (
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
if (target.value !== searchQuery) {
setSearchQuery(target.value);
debouncedFetchData(target.value); // Call the debounced fetch function with the updated search query
}
};
useEffect(() => {
debouncedFetchData(q);
}, []);
console.log({ data, isLoading });
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" />
<div class="mt-2">
<div
class="flex items-center gap-1 rounded-xl w-full shadow-2xl"
style={{ background: "#2B2930", color: "#818181" }}
>
{isLoading
? <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"
class=""
style={{ fontSize: "1.2em" }}
class="bg-transparent py-3 w-full"
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>
)}
{data?.hits?.length && !isLoading
? <SearchResultList showEmoji={!type} result={data} />
: isLoading
? <div />
: (
<div
class="flex items-center gap-2 p-2 my-4 mx-3"
style={{ color: "#818181" }}
>
<IconGhost class="animate-hover" />
No Results
</div>
)}
</div>
);
};

View File

@ -1,6 +1,5 @@
import {
createDocument,
Document,
getDocument,
getDocuments,
transformDocument,
@ -20,7 +19,6 @@ export function createCrud<T>(
async function read(id: string) {
const path = pathFromId(id);
const content = await getDocument(path);
return parse(content, id);
}
function create(id: string, content: string | ArrayBuffer) {

View File

@ -1,79 +0,0 @@
import { refractor } from "https://esm.sh/refractor@4.8.1";
import { visit } from "https://esm.sh/unist-util-visit@5.0.0";
import { toString } from "https://esm.sh/hast-util-to-string@2.0.0";
import jsx from "https://esm.sh/refractor/lang/jsx";
import javascript from "https://esm.sh/refractor/lang/javascript";
import css from "https://esm.sh/refractor/lang/css";
import cssExtras from "https://esm.sh/refractor/lang/css-extras";
import jsExtras from "https://esm.sh/refractor/lang/js-extras";
import sql from "https://esm.sh/refractor/lang/sql";
import typescript from "https://esm.sh/refractor/lang/typescript";
import swift from "https://esm.sh/refractor/lang/swift";
import objectivec from "https://esm.sh/refractor/lang/objectivec";
import markdown from "https://esm.sh/refractor/lang/markdown";
import json from "https://esm.sh/refractor/lang/json";
refractor.register(jsx);
refractor.register(json);
refractor.register(typescript);
refractor.register(javascript);
refractor.register(css);
refractor.register(cssExtras);
refractor.register(jsExtras);
refractor.register(sql);
refractor.register(swift);
refractor.register(objectivec);
refractor.register(markdown);
refractor.alias({ jsx: ["js"] });
refractor.alias({typescript:["ts"]})
const getLanguage = (node) => {
const className = node.properties.className || [];
for (const classListItem of className) {
if (classListItem.slice(0, 9) === "language-") {
return classListItem.slice(9).toLowerCase();
}
}
return null;
};
const rehypePrism = (options) => {
options = options || {};
return (tree) => {
visit(tree, "element", visitor);
};
function visitor(node, index, parent) {
if (!parent || parent.tagName !== "pre" || node.tagName !== "code") {
return;
}
const lang = getLanguage(node);
if (lang === null) {
return;
}
let result;
try {
parent.properties.className = (parent.properties.className || []).concat(
"language-" + lang,
);
result = refractor.highlight(toString(node), lang);
} catch (err) {
if (options.ignoreMissing && /Unknown language/.test(err.message)) {
return;
}
throw err;
}
node.children = result;
}
};
export default rehypePrism;

View File

@ -1,24 +1,286 @@
// useDebouncedCallback.tsx
import { useEffect, useState } from "preact/hooks";
import { useEffect, useMemo, useRef } from "preact/hooks";
const useDebouncedCallback = (
callback: (...args: any[]) => void,
delay: number,
) => {
const [debouncedCallback, setDebouncedCallback] = useState(() => callback);
export interface CallOptions {
/**
* Controls if the function should be invoked on the leading edge of the timeout.
*/
leading?: boolean;
/**
* Controls if the function should be invoked on the trailing edge of the timeout.
*/
trailing?: boolean;
}
export interface Options extends CallOptions {
/**
* The maximum time the given function is allowed to be delayed before it's invoked.
*/
maxWait?: number;
}
export interface ControlFunctions {
/**
* Cancel pending function invocations
*/
cancel: () => void;
/**
* Immediately invoke pending function invocations
*/
flush: () => void;
/**
* Returns `true` if there are any pending function invocations
*/
isPending: () => boolean;
}
/**
* Subsequent calls to the debounced function `debounced.callback` return the result of the last func invocation.
* Note, that if there are no previous invocations it's mean you will get undefined. You should check it in your code properly.
*/
export interface DebouncedState<T extends (...args: any) => ReturnType<T>>
extends ControlFunctions {
(...args: Parameters<T>): ReturnType<T> | undefined;
}
/**
* Creates a debounced function that delays invoking `func` until after `wait`
* milliseconds have elapsed since the last time the debounced function was
* invoked, or until the next browser frame is drawn.
*
* The debounced function comes with a `cancel` method to cancel delayed `func`
* invocations and a `flush` method to immediately invoke them.
*
* Provide `options` to indicate whether `func` should be invoked on the leading
* and/or trailing edge of the `wait` timeout. The `func` is invoked with the
* last arguments provided to the debounced function.
*
* Subsequent calls to the debounced function return the result of the last
* `func` invocation.
*
* **Note:** If `leading` and `trailing` options are `true`, `func` is
* invoked on the trailing edge of the timeout only if the debounced function
* is invoked more than once during the `wait` timeout.
*
* If `wait` is `0` and `leading` is `false`, `func` invocation is deferred
* until the next tick, similar to `setTimeout` with a timeout of `0`.
*
* If `wait` is omitted in an environment with `requestAnimationFrame`, `func`
* invocation will be deferred until the next frame is drawn (typically about
* 16ms).
*
* See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
* for details over the differences between `debounce` and `throttle`.
*
* @category Function
* @param {Function} func The function to debounce.
* @param {number} [wait=0]
* The number of milliseconds to delay; if omitted, `requestAnimationFrame` is
* used (if available, otherwise it will be setTimeout(...,0)).
* @param {Object} [options={}] The options object.
* Controls if `func` should be invoked on the leading edge of the timeout.
* @param {boolean} [options.leading=false]
* The maximum time `func` is allowed to be delayed before it's invoked.
* @param {number} [options.maxWait]
* Controls if `func` should be invoked the trailing edge of the timeout.
* @param {boolean} [options.trailing=true]
* @returns {Function} Returns the new debounced function.
* @example
*
* // Avoid costly calculations while the window size is in flux.
* const resizeHandler = useDebouncedCallback(calculateLayout, 150);
* window.addEventListener('resize', resizeHandler)
*
* // Invoke `sendMail` when clicked, debouncing subsequent calls.
* const clickHandler = useDebouncedCallback(sendMail, 300, {
* leading: true,
* trailing: false,
* })
* <button onClick={clickHandler}>click me</button>
*
* // Ensure `batchLog` is invoked once after 1 second of debounced calls.
* const debounced = useDebouncedCallback(batchLog, 250, { 'maxWait': 1000 })
* const source = new EventSource('/stream')
* source.addEventListener('message', debounced)
*
* // Cancel the trailing debounced invocation.
* window.addEventListener('popstate', debounced.cancel)
*
* // Check for pending invocations.
* const status = debounced.pending() ? "Pending..." : "Ready"
*/
export default function useDebouncedCallback<
T extends (...args: any) => ReturnType<T>,
>(
func: T,
wait?: number,
options?: Options,
): DebouncedState<T> {
const lastCallTime = useRef(null);
const lastInvokeTime = useRef(0);
const timerId = useRef(null);
const lastArgs = useRef<unknown[]>([]);
const lastThis = useRef<unknown>();
const result = useRef<ReturnType<T>>();
const funcRef = useRef(func);
const mounted = useRef(true);
useEffect(() => {
const debounceHandler = setTimeout(() => {
setDebouncedCallback(() => callback);
}, delay);
funcRef.current = func;
}, [func]);
// Bypass `requestAnimationFrame` by explicitly setting `wait=0`.
const useRAF = !wait && wait !== 0 && typeof window !== "undefined";
if (typeof func !== "function") {
throw new TypeError("Expected a function");
}
wait = +wait || 0;
options = options || {};
const leading = !!options.leading;
const trailing = "trailing" in options ? !!options.trailing : true; // `true` by default
const maxing = "maxWait" in options;
const maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : null;
useEffect(() => {
mounted.current = true;
return () => {
clearTimeout(debounceHandler);
mounted.current = false;
};
}, [callback, delay]);
}, []);
return debouncedCallback;
};
// You may have a question, why we have so many code under the useMemo definition.
//
// This was made as we want to escape from useCallback hell and
// not to initialize a number of functions each time useDebouncedCallback is called.
//
// It means that we have less garbage for our GC calls which improves performance.
// Also, it makes this library smaller.
//
// And the last reason, that the code without lots of useCallback with deps is easier to read.
// You have only one place for that.
const debounced = useMemo(() => {
const invokeFunc = (time: number) => {
const args = lastArgs.current;
const thisArg = lastThis.current;
export default useDebouncedCallback;
lastArgs.current = lastThis.current = null;
lastInvokeTime.current = time;
return (result.current = funcRef.current.apply(thisArg, args));
};
const startTimer = (pendingFunc: () => void, wait: number) => {
if (useRAF) cancelAnimationFrame(timerId.current);
timerId.current = useRAF
? requestAnimationFrame(pendingFunc)
: setTimeout(pendingFunc, wait);
};
const shouldInvoke = (time: number) => {
if (!mounted.current) return false;
const timeSinceLastCall = time - lastCallTime.current;
const timeSinceLastInvoke = time - lastInvokeTime.current;
// Either this is the first call, activity has stopped and we're at the
// trailing edge, the system time has gone backwards and we're treating
// it as the trailing edge, or we've hit the `maxWait` limit.
return (
!lastCallTime.current ||
timeSinceLastCall >= wait ||
timeSinceLastCall < 0 ||
(maxing && timeSinceLastInvoke >= maxWait)
);
};
const trailingEdge = (time: number) => {
timerId.current = null;
// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (trailing && lastArgs.current) {
return invokeFunc(time);
}
lastArgs.current = lastThis.current = null;
return result.current;
};
const timerExpired = () => {
const time = Date.now();
if (shouldInvoke(time)) {
return trailingEdge(time);
}
// https://github.com/xnimorz/use-debounce/issues/97
if (!mounted.current) {
return;
}
// Remaining wait calculation
const timeSinceLastCall = time - lastCallTime.current;
const timeSinceLastInvoke = time - lastInvokeTime.current;
const timeWaiting = wait - timeSinceLastCall;
const remainingWait = maxing
? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
: timeWaiting;
// Restart the timer
startTimer(timerExpired, remainingWait);
};
const func: DebouncedState<T> = (...args: Parameters<T>): ReturnType<T> => {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs.current = args;
lastThis.current = this;
lastCallTime.current = time;
if (isInvoking) {
if (!timerId.current && mounted.current) {
// Reset any `maxWait` timer.
lastInvokeTime.current = lastCallTime.current;
// Start the timer for the trailing edge.
startTimer(timerExpired, wait);
// Invoke the leading edge.
return leading ? invokeFunc(lastCallTime.current) : result.current;
}
if (maxing) {
// Handle invocations in a tight loop.
startTimer(timerExpired, wait);
return invokeFunc(lastCallTime.current);
}
}
if (!timerId.current) {
startTimer(timerExpired, wait);
}
return result.current;
};
func.cancel = () => {
if (timerId.current) {
useRAF
? cancelAnimationFrame(timerId.current)
: clearTimeout(timerId.current);
}
lastInvokeTime.current = 0;
lastArgs.current =
lastCallTime.current =
lastThis.current =
timerId.current =
null;
};
func.isPending = () => {
return !!timerId.current;
};
func.flush = () => {
return !timerId.current ? result.current : trailingEdge(Date.now());
};
return func;
}, [leading, maxing, wait, maxWait, trailing, useRAF]);
return debounced;
}

View File

@ -0,0 +1,17 @@
import { useEffect, useRef } from "preact/hooks";
export function useTraceUpdate(props) {
const prev = useRef(props);
useEffect(() => {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
if (prev.current[k] !== v) {
ps[k] = [prev.current[k], v];
}
return ps;
}, {});
if (Object.keys(changedProps).length > 0) {
console.log("Changed props:", changedProps);
}
prev.current = props;
});
}

View File

@ -1,18 +0,0 @@
export const menu = [
{
name: "🏡 Home",
link: "/",
},
{
name: "🍽️ Recipes",
link: "/recipes",
},
{
name: "🍿 Movies",
link: "/movies",
},
{
name: "📝 Articles",
link: "/articles",
},
];

View File

@ -3,10 +3,9 @@ import {
getTextOfChild,
getTextOfRange,
parseDocument,
renderMarkdown,
} from "@lib/documents.ts";
import { parse } from "yaml";
import { parseIngredient } from "https://esm.sh/parse-ingredient";
import { parseIngredient } from "https://esm.sh/parse-ingredient@1.0.1";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags } from "@lib/string.ts";

26
lib/resources.ts Normal file
View File

@ -0,0 +1,26 @@
export const resources = {
"home": {
emoji: "🏡",
name: "Home",
link: "/",
prefix: "",
},
"recipe": {
emoji: "🍽️",
name: "Recipes",
link: "/recipes",
prefix: "Recipes/",
},
"movie": {
emoji: "🍿",
name: "Movies",
link: "/movies",
prefix: "Media/movies/",
},
"article": {
emoji: "📝",
name: "Articles",
link: "/articles",
prefix: "Media/articles/",
},
} as const;

View File

@ -1,3 +1,5 @@
import { resources } from "@lib/resources.ts";
export function formatDate(date: Date): string {
const options = { year: "numeric", month: "long", day: "numeric" } as const;
return new Intl.DateTimeFormat("en-US", options).format(date);
@ -92,3 +94,9 @@ export function getCookie(name: string): string | null {
return decodeURIComponent(cookie.substring(nameLenPlus));
})[0] || null;
}
const resourcePrefixes = Object.values(resources).map((v) => v.prefix).filter(
(s) => s.length > 2,
);
export const isLocalImage = (src: string) =>
resourcePrefixes.some((p) => src.startsWith(p));

View File

@ -1,3 +1,6 @@
import { SearchResponse } from "https://raw.githubusercontent.com/bradenmacdonald/typesense-deno/main/src/Typesense/Documents.ts";
import { resources } from "@lib/resources.ts";
export interface TMDBMovie {
adult: boolean;
backdrop_path: string;
@ -23,3 +26,14 @@ export interface GiteaOauthUser {
picture: string;
groups: any;
}
export type SearchResult = SearchResponse<{
id:string;
name: string;
type: keyof typeof resources;
date?: string;
rating: number;
tags: string[];
description?: string;
image?: string;
}>;

View File

@ -29,13 +29,6 @@ function sanitizeStringForTypesense(input: string) {
// Use a promise to initialize the client as needed, rather than at import time.
let clientPromise: Promise<Client | null> | undefined;
clientPromise?.then((client) => {
client?.collections().create({
name: "resources",
fields: [],
});
});
export function getTypeSenseClient(): Promise<Client | null> {
if (clientPromise === undefined) {
let typesenseUrl: URL;
@ -83,6 +76,7 @@ async function initializeTypesense() {
{ name: "rating", type: "int32", facet: true },
{ name: "tags", type: "string[]", facet: true },
{ name: "description", type: "string", optional: true },
{ name: "image", type: "string", optional: true },
],
default_sorting_field: "rating", // Default field for sorting
});
@ -117,6 +111,7 @@ async function synchronizeWithTypesense() {
description: sanitizeStringForTypesense(
resource?.description || resource?.content || "",
),
image: resource?.meta?.image,
tags: resource?.tags || [],
rating: resource?.meta?.rating || 0,
date: resource?.meta?.date?.toString() || "",
@ -130,23 +125,29 @@ async function synchronizeWithTypesense() {
);
// Get all the IDs of documents currently indexed in Typesense
// const allTypesenseDocuments = await client.collections("resources")
// .documents()
// .search({ q: "*", query_by: "name" });
const allTypesenseDocuments = await client.collections("resources")
.documents().search({
q: "*",
query_by: "name,type,date,description",
per_page: 250,
limit_hits: 9999,
});
// const documentIds = allTypesenseDocuments.hits?.map((doc) => doc.id);
const documentIds = allTypesenseDocuments.hits?.map((doc) =>
doc?.document?.id
) as string[];
// Find deleted document IDs by comparing the Typesense document IDs with the current list of resources
// const deletedDocumentIds = documentIds?.filter((id) =>
// !allResources.some((resource) => resource.id.toString() === id)
// );
const deletedDocumentIds = documentIds?.filter((id) =>
!allResources.some((resource) => resource.id.toString() === id)
);
// Delete the documents with IDs found in deletedDocumentIds
// await Promise.all(
// deletedDocumentIds?.map((id) =>
// client.collections("resources").documents()
// ),
// );
await Promise.all(
deletedDocumentIds?.map((id) =>
client.collections("resources").documents(id).delete()
),
);
log.info("data synchronized");
} catch (error) {

View File

@ -7,6 +7,7 @@ export default function App({ Component }: AppProps) {
<Head>
<link href="/global.css" rel="stylesheet" />
<link href="/prism-material-dark.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="/favicon.png" />
</Head>
<Component />
</>

View File

@ -2,6 +2,10 @@ import { Handlers } from "$fresh/server.ts";
import { BadRequestError } from "@lib/errors.ts";
import { getTypeSenseClient } from "@lib/typesense.ts";
import { json } from "@lib/helpers.ts";
import { getArticle } from "@lib/resource/articles.ts";
import { getMovie } from "@lib/tmdb.ts";
import { getRecipe } from "@lib/resource/recipes.ts";
import { getDocument } from "@lib/documents.ts";
export const handler: Handlers = {
async GET(req, _ctx) {
@ -30,8 +34,9 @@ export const handler: Handlers = {
q: query,
query_by,
filter_by,
per_page: 50,
});
return json(searchResults.hits);
return json(searchResults);
},
};

View File

@ -6,6 +6,7 @@ import { KMenu } from "@islands/KMenu.tsx";
import { YoutubePlayer } from "@components/Youtube.tsx";
import { HashTags } from "@components/HashTags.tsx";
import { isYoutubeLink } from "@lib/string.ts";
import { renderMarkdown } from "@lib/documents.ts";
export const handler: Handlers<Article | null> = {
async GET(_, ctx) {
@ -19,6 +20,8 @@ export default function Greet(props: PageProps<Article>) {
const { author = "", date = "" } = article.meta;
const content = renderMarkdown(article.content);
return (
<MainLayout url={props.url} title={`Article > ${article.name}`}>
<RecipeHero
@ -43,9 +46,9 @@ export default function Greet(props: PageProps<Article>) {
class="whitespace-break-spaces markdown-body"
data-color-mode="dark"
data-dark-theme="dark"
dangerouslySetInnerHTML={{ __html: article.content || "" }}
dangerouslySetInnerHTML={{ __html: content || "" }}
>
{article.content||""}
{content||""}
</pre>
</div>
</MainLayout>

View File

@ -2,7 +2,7 @@ import { Head } from "$fresh/runtime.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { Card } from "@components/Card.tsx";
import { PageProps } from "$fresh/server.ts";
import { menu } from "@lib/menus.ts";
import { resources } from "@lib/resources.ts";
import { KMenu } from "@islands/KMenu.tsx";
export default function Home(props: PageProps) {
@ -14,10 +14,10 @@ export default function Home(props: PageProps) {
<KMenu type="main" context={null} />
<MainLayout url={props.url}>
<div class="flex flex-wrap items-center gap-4 px-4">
{menu.map((m) => {
{Object.values(resources).map((m) => {
return (
<Card
title={m.name}
title={`${m.emoji} ${m.name}`}
image="/placeholder.svg"
link={m.link}
/>

View File

@ -4,6 +4,7 @@ import { getMovie, Movie } from "@lib/resource/movies.ts";
import { RecipeHero } from "@components/RecipeHero.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import { HashTags } from "@components/HashTags.tsx";
import { renderMarkdown } from "@lib/documents.ts";
export const handler: Handlers<Movie | null> = {
async GET(_, ctx) {
@ -17,6 +18,8 @@ export default function Greet(props: PageProps<Movie>) {
const { author = "", date = "" } = movie.meta;
const content = renderMarkdown(movie.description || "");
return (
<MainLayout url={props.url} title={`Movie > ${movie.name}`}>
<RecipeHero
@ -38,9 +41,9 @@ export default function Greet(props: PageProps<Movie>) {
: <></>}
<pre
class="whitespace-break-spaces"
dangerouslySetInnerHTML={{ __html: movie.description || "" }}
dangerouslySetInnerHTML={{ __html: content || "" }}
>
{movie.description}
{content}
</pre>
</div>
</MainLayout>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

View File

@ -17,7 +17,7 @@
}
body {
background: #141217;
background: #141218;
padding: 0px 20px;
}
pre {
@ -25,7 +25,8 @@ pre {
}
a {
color: cadetblue
color: cadetblue;
font-family: Work Sans;
}
.custom-grid {
@ -38,6 +39,19 @@ a {
}
}
.animate-hover {
animation: hover 4s infinite;
}
@keyframes hover {
0%, 100% {
transform: translateY(-15%);
}
50% {
transform: translateY(0);
}
}
.noisy-gradient::after {
content: "";
top: 0;