fix: make search usable again

This commit is contained in:
Max Richter
2025-11-05 00:42:53 +01:00
parent 7664abe089
commit 581f1c1926
9 changed files with 120 additions and 62 deletions

View File

@@ -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<number | undefined>;
},
) => {
const [rating, setRating] = useState(props.rating);
const [hover, setHover] = useState(0);
const max = useSignal(props.max || 5);
const ratingValue = rating.value || 0;
return (
<div
class="flex items-center gap-2 px-5 rounded-2xl bg-gray-200 z-10"
class="flex items-center gap-2 px-5 rounded-2xl bg-gray-200 z-10 h-full"
style={{ color: "var(--foreground)", background: "var(--background)" }}
>
{Array.from({ length: max.value }).map((_, i) => {
{Array.from({ length: max || 5 }).map((_, i) => {
return (
<span
class={`cursor-pointer opacity-${
(i + 1) <= rating ? 100 : (i + 1) <= hover ? 20 : 100
(i + 1) <= ratingValue ? 100 : (i + 1) <= hover ? 20 : 100
}`}
onMouseOver={() => setHover(i + 1)}
onClick={() => setRating(i + 1)}
onClick={() => (rating.value = i + 1)}
>
{(i + 1) <= rating || (i + 1) <= hover
{(i + 1) <= ratingValue || (i + 1) <= hover
? <IconStarFilled class="w-4 h-4" />
: <IconStar class="w-4 h-4" />}
</span>

View File

@@ -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 (
<Search
q={_url.searchParams.get("q") || ""}
{...context}
q={q}
results={searchResults}
/>
);

View File

@@ -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<GenericResource[] | undefined>(results);
const isLoading = useSignal(false);
const rating = useSignal<number | undefined>(undefined);
const showSeenStatus = useSignal(false);
const inputRef = useRef<HTMLInputElement>(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}
/>
</div>
<Checkbox label="seen" checked={showSeenStatus} />
<Rating rating={4} />
<Checkbox label="unseen" checked={showSeenStatus} />
<div
class={showSeenStatus.value ? "opacity-10" : ""}
>
<Rating rating={rating} />
</div>
</header>
{data.value?.length && !isLoading.value
? <SearchResultList showEmoji={!type} result={data.value} />

View File

@@ -31,6 +31,9 @@ const cacheLock = new Map<string, Promise<Resource>>();
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<T extends Resource>(
export async function fetchResource<T extends GenericResource>(
resource: string,
): Promise<T | undefined> {
try {
@@ -80,6 +83,7 @@ export async function listResources<T extends GenericResource>(
.map((res) => addImageToResource(res) as Promise<T>),
);
} catch (_e) {
console.log(`Failed to fetch resource: ${resource}`, _e);
return [];
}
}

View File

@@ -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<GenericResource[]> {
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<string, GenericResource> = {};
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",

View File

@@ -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(/^\.+/, "")

View File

@@ -74,6 +74,11 @@ export interface PageInfo {
resultsPerPage: number;
}
export async function getYoutubeVideoCover(id: string): Promise<ArrayBuffer> {
const res = await fetch(`https://i.ytimg.com/vi/${id}/maxresdefault.jpg`);
return res.arrayBuffer();
}
export async function getYoutubeVideoDetails(
id: string,
): Promise<Item> {
@@ -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];
}

View File

@@ -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<typeof createStreamResponse>,
): Promise<string | undefined> {
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 = {

View File

@@ -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);
},