From 581f1c1926f6ed5a18e81f1745d8b3a8c61bb229 Mon Sep 17 00:00:00 2001 From: Max Richter Date: Wed, 5 Nov 2025 00:42:53 +0100 Subject: [PATCH] fix: make search usable again --- components/Rating.tsx | 22 +++++---- components/layouts/main.tsx | 16 +++++-- islands/Search.tsx | 17 ++++--- lib/marka/index.ts | 6 ++- lib/search.ts | 22 ++++++--- lib/string.ts | 2 +- lib/youtube.ts | 6 ++- routes/api/articles/create/index.ts | 70 +++++++++++++++++++---------- routes/api/query/index.ts | 21 ++++----- 9 files changed, 120 insertions(+), 62 deletions(-) diff --git a/components/Rating.tsx b/components/Rating.tsx index 26e3a5c..b5bfca0 100644 --- a/components/Rating.tsx +++ b/components/Rating.tsx @@ -1,5 +1,5 @@ import { IconStar, IconStarFilled } from "@components/icons.tsx"; -import { useSignal } from "@preact/signals"; +import { Signal, useSignal } from "@preact/signals"; import { useState } from "preact/hooks"; export const SmallRating = ( @@ -24,27 +24,31 @@ export const SmallRating = ( }; export const Rating = ( - props: { max?: number; rating: number }, + { max, rating = useSignal(0) }: { + max?: number; + defaultRating: number; + rating: Signal; + }, ) => { - const [rating, setRating] = useState(props.rating); const [hover, setHover] = useState(0); - const max = useSignal(props.max || 5); + + const ratingValue = rating.value || 0; return (
- {Array.from({ length: max.value }).map((_, i) => { + {Array.from({ length: max || 5 }).map((_, i) => { return ( setHover(i + 1)} - onClick={() => setRating(i + 1)} + onClick={() => (rating.value = i + 1)} > - {(i + 1) <= rating || (i + 1) <= hover + {(i + 1) <= ratingValue || (i + 1) <= hover ? : } diff --git a/components/layouts/main.tsx b/components/layouts/main.tsx index 0c875b4..0bad2f1 100644 --- a/components/layouts/main.tsx +++ b/components/layouts/main.tsx @@ -12,17 +12,25 @@ export type Props = { searchResults?: GenericResource[]; }; +function getQFromUrl(u: string | URL): string | null { + try { + const _u = typeof u === "string" ? new URL(u) : u; + return _u?.searchParams.get("q"); + } catch (_e) { + return null; + } +} + export const MainLayout = ( { children, url, context, searchResults }: Props, ) => { - const _url = typeof url === "string" ? new URL(url) : url; - const hasSearch = _url?.search?.includes("q="); + const q = getQFromUrl(url); - if (hasSearch) { + if (typeof q === "string") { return ( ); diff --git a/islands/Search.tsx b/islands/Search.tsx index 16a5edb..b8fec47 100644 --- a/islands/Search.tsx +++ b/islands/Search.tsx @@ -23,7 +23,7 @@ export async function fetchQueryResource(url: URL, type = "") { url.searchParams.set("status", "not-seen"); } if (type) { - url.searchParams.set("types", type); + url.searchParams.set("type", type); } const response = await fetch(url); const jsonData = await response.json(); @@ -114,6 +114,7 @@ const Search = ( const searchQuery = useSignal(q); const data = useSignal(results); const isLoading = useSignal(false); + const rating = useSignal(undefined); const showSeenStatus = useSignal(false); const inputRef = useRef(null); @@ -122,8 +123,10 @@ const Search = ( if (u.searchParams.get("q") !== searchQuery.value) { u.searchParams.set("q", searchQuery.value); } - if (showSeenStatus.value) { + if (showSeenStatus.value === true) { u.searchParams.set("rating", "0"); + } else if (rating.value) { + u.searchParams.set("rating", rating.value.toString()); } else { u.searchParams.delete("rating"); } @@ -162,7 +165,7 @@ const Search = ( useEffect(() => { debouncedFetchData(); // Call the debounced fetch function with the updated search query - }, [searchQuery.value, showSeenStatus.value]); + }, [searchQuery.value, showSeenStatus.value, rating.value]); useEffect(() => { debouncedFetchData(); @@ -187,8 +190,12 @@ const Search = ( onInput={handleInputChange} />
- - + +
+ +
{data.value?.length && !isLoading.value ? diff --git a/lib/marka/index.ts b/lib/marka/index.ts index c6b3371..1b362a4 100644 --- a/lib/marka/index.ts +++ b/lib/marka/index.ts @@ -31,6 +31,9 @@ const cacheLock = new Map>(); async function fetchAndStoreUrl(url: string) { const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch resource: ${response.status}`); + } const res = await response.json(); fetchCache.set(url, res); return res; @@ -54,7 +57,7 @@ async function cachedFetch( return res; } -export async function fetchResource( +export async function fetchResource( resource: string, ): Promise { try { @@ -80,6 +83,7 @@ export async function listResources( .map((res) => addImageToResource(res) as Promise), ); } catch (_e) { + console.log(`Failed to fetch resource: ${resource}`, _e); return []; } } diff --git a/lib/search.ts b/lib/search.ts index 339a6e6..efebbcb 100644 --- a/lib/search.ts +++ b/lib/search.ts @@ -19,7 +19,7 @@ export function parseResourceUrl(_url: string | URL): SearchParams | undefined { try { const url = typeof _url === "string" ? new URL(_url) : _url; let query = url.searchParams.get("q") || "*"; - if (!query) { + if (!(typeof query === "string")) { return undefined; } query = decodeURIComponent(query); @@ -54,12 +54,14 @@ export async function searchResource( { q, tags = [], types, rating }: SearchParams, ): Promise { const resources = (await Promise.all([ - (!types || types.includes("movie")) && listResources("movies"), + (!types || types.includes("movies")) && listResources("movies"), (!types || types.includes("series")) && listResources("series"), - (!types || types.includes("article")) && listResources("articles"), - (!types || types.includes("recipe")) && listResources("recipes"), + (!types || types.includes("articles")) && listResources("articles"), + (!types || types.includes("recipes")) && listResources("recipes"), ])).flat().filter(isResource); + console.log({ types, rating, tags, q }); + const results: Record = {}; for (const resource of resources) { @@ -71,9 +73,18 @@ export async function searchResource( results[resource.name] = resource; } + // Select not-rated resources + if ( + rating === 0 && + resource.content?.reviewRating?.ratingValue === undefined + ) { + results[resource.name] = resource; + } + if ( !(resource.name in results) && - rating && resource.content.reviewRating?.ratingValue && + typeof rating == "number" && + resource.content.reviewRating?.ratingValue && parseRating(resource.content.reviewRating.ratingValue) >= rating ) { results[resource.name] = resource; @@ -81,6 +92,7 @@ export async function searchResource( } if (q.length && q !== "*") { + q = decodeURIComponent(q); const fuzzyResult = fuzzysort.go(q, resources, { keys: [ "name", diff --git a/lib/string.ts b/lib/string.ts index c88498a..cd64b87 100644 --- a/lib/string.ts +++ b/lib/string.ts @@ -17,7 +17,7 @@ export function safeFileName(input: string): string { .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/[\s-]+/g, "_") - .replace(/[^A-Za-z0-9._]+/g, "") + .replace(/[^A-Za-z0-9_]+/g, "") .replace(/_+/g, "_") // Trim underscores/dots from ends and prevent leading dots .replace(/^[_\.]+|[_\.]+$/g, "").replace(/^\.+/, "") diff --git a/lib/youtube.ts b/lib/youtube.ts index c276a8e..f4db60d 100644 --- a/lib/youtube.ts +++ b/lib/youtube.ts @@ -74,6 +74,11 @@ export interface PageInfo { resultsPerPage: number; } +export async function getYoutubeVideoCover(id: string): Promise { + const res = await fetch(`https://i.ytimg.com/vi/${id}/maxresdefault.jpg`); + return res.arrayBuffer(); +} + export async function getYoutubeVideoDetails( id: string, ): Promise { @@ -81,6 +86,5 @@ export async function getYoutubeVideoDetails( `${BASE_URL}videos?part=snippet%2CcontentDetails%2Cstatistics&id=${id}&key=${YOUTUBE_API_KEY}`, ); const json = await response.json(); - return json?.items[0]; } diff --git a/routes/api/articles/create/index.ts b/routes/api/articles/create/index.ts index 46ff098..d4bcef6 100644 --- a/routes/api/articles/create/index.ts +++ b/routes/api/articles/create/index.ts @@ -19,6 +19,32 @@ import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts" const log = createLogger("api/article"); +async function fetchAndStoreCover( + imageUrl: string | undefined, + title: string, + streamResponse?: ReturnType, +): Promise { + if (!imageUrl) return; + const imagePath = `articles/images/${safeFileName(title)}_cover.${ + fileExtension(imageUrl) + }`; + try { + streamResponse?.enqueue("downloading image"); + const res = await fetch(imageUrl); + streamResponse?.enqueue("saving image"); + if (!res.ok) { + console.log(`Failed to download remote image: ${imageUrl}`, res.status); + return; + } + const buffer = await res.arrayBuffer(); + await createResource(imagePath, buffer); + return `resources/${imagePath}`; + } catch (err) { + console.log(`Failed to save image: ${imageUrl}`, err); + return; + } +} + async function processCreateArticle( { fetchUrl, streamResponse }: { fetchUrl: string; @@ -49,23 +75,11 @@ async function processCreateArticle( const title = result?.title || aiMeta?.headline || ""; - let finalPath = result.image; - if (result?.image) { - const extension = fileExtension(result?.image); - const imagePath = `articles/images/${ - safeFileName(title) - }_cover.${extension}`; - try { - streamResponse.enqueue("downloading image"); - const res = await fetch(result.image); - streamResponse.enqueue("saving image"); - const buffer = await res.arrayBuffer(); - await createResource(imagePath, buffer); - finalPath = imagePath; - } catch (err) { - console.log(`Failed to save image: ${result.image}`, err); - } - } + const coverImagePath = await fetchAndStoreCover( + result.image, + title, + streamResponse, + ); const newArticle: ArticleResource["content"] = { _type: "Article", @@ -75,7 +89,7 @@ async function processCreateArticle( datePublished: formatDate( result?.published || aiMeta?.datePublished || undefined, ), - image: finalPath, + image: coverImagePath, author: { _type: "Person", name: (result.schemaOrgData?.author?.name || aiMeta?.author || "") @@ -115,16 +129,23 @@ async function processCreateYoutubeVideo( const video = await getYoutubeVideoDetails(youtubeId); streamResponse.enqueue("shortening title with openai"); - const newId = await openai.shortenTitle(video.snippet.title); + const videoTitle = await openai.shortenTitle(video.snippet.title) || + video.snippet.title; - const id = newId || youtubeId; + const thumbnail = video?.snippet?.thumbnails?.maxres; + const coverImagePath = await fetchAndStoreCover( + thumbnail.url, + videoTitle || video.snippet.title, + streamResponse, + ); const newArticle: ArticleResource["content"] = { _type: "Article", headline: video.snippet.title, articleBody: video.snippet.description, + image: coverImagePath, url: fetchUrl, - datePublished: new Date(video.snippet.publishedAt).toISOString(), + datePublished: formatDate(video.snippet.publishedAt), author: { _type: "Person", name: video.snippet.channelTitle, @@ -133,11 +154,14 @@ async function processCreateYoutubeVideo( streamResponse.enqueue("creating article"); - await createResource(`articles/${id}.md`, newArticle); + await createResource( + `articles/${toUrlSafeString(videoTitle)}.md`, + newArticle, + ); streamResponse.enqueue("finished"); - streamResponse.enqueue("id: " + id); + streamResponse.enqueue("id: " + toUrlSafeString(videoTitle)); } export const handler: Handlers = { diff --git a/routes/api/query/index.ts b/routes/api/query/index.ts index d3ac300..c6f1d24 100644 --- a/routes/api/query/index.ts +++ b/routes/api/query/index.ts @@ -1,7 +1,7 @@ import { Handlers } from "$fresh/server.ts"; import { json } from "@lib/helpers.ts"; -import { AccessDeniedError } from "@lib/errors.ts"; -import { searchResource } from "@lib/search.ts"; +import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; +import { parseResourceUrl, searchResource } from "@lib/search.ts"; export const handler: Handlers = { async GET(req, ctx) { @@ -10,18 +10,13 @@ export const handler: Handlers = { throw new AccessDeniedError(); } - const url = new URL(req.url); + const s = parseResourceUrl(req.url); + if (!s) { + throw new BadRequestError(); + } - const types = url.searchParams.get("types")?.split(","); - const tags = url.searchParams?.get("tags")?.split(","); - const authors = url.searchParams?.get("authors")?.split(","); - - const resources = await searchResource({ - q: url.searchParams.get("q") || "", - types, - tags, - authors, - }); + console.log(s); + const resources = await searchResource(s); return json(resources); },