fix: make search usable again
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { IconStar, IconStarFilled } from "@components/icons.tsx";
|
import { IconStar, IconStarFilled } from "@components/icons.tsx";
|
||||||
import { useSignal } from "@preact/signals";
|
import { Signal, useSignal } from "@preact/signals";
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
|
|
||||||
export const SmallRating = (
|
export const SmallRating = (
|
||||||
@@ -24,27 +24,31 @@ export const SmallRating = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Rating = (
|
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 [hover, setHover] = useState(0);
|
||||||
const max = useSignal(props.max || 5);
|
|
||||||
|
const ratingValue = rating.value || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)" }}
|
style={{ color: "var(--foreground)", background: "var(--background)" }}
|
||||||
>
|
>
|
||||||
{Array.from({ length: max.value }).map((_, i) => {
|
{Array.from({ length: max || 5 }).map((_, i) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
class={`cursor-pointer opacity-${
|
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)}
|
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" />
|
? <IconStarFilled class="w-4 h-4" />
|
||||||
: <IconStar class="w-4 h-4" />}
|
: <IconStar class="w-4 h-4" />}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -12,17 +12,25 @@ export type Props = {
|
|||||||
searchResults?: GenericResource[];
|
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 = (
|
export const MainLayout = (
|
||||||
{ children, url, context, searchResults }: Props,
|
{ children, url, context, searchResults }: Props,
|
||||||
) => {
|
) => {
|
||||||
const _url = typeof url === "string" ? new URL(url) : url;
|
const q = getQFromUrl(url);
|
||||||
const hasSearch = _url?.search?.includes("q=");
|
|
||||||
|
|
||||||
if (hasSearch) {
|
if (typeof q === "string") {
|
||||||
return (
|
return (
|
||||||
<Search
|
<Search
|
||||||
q={_url.searchParams.get("q") || ""}
|
|
||||||
{...context}
|
{...context}
|
||||||
|
q={q}
|
||||||
results={searchResults}
|
results={searchResults}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function fetchQueryResource(url: URL, type = "") {
|
|||||||
url.searchParams.set("status", "not-seen");
|
url.searchParams.set("status", "not-seen");
|
||||||
}
|
}
|
||||||
if (type) {
|
if (type) {
|
||||||
url.searchParams.set("types", type);
|
url.searchParams.set("type", type);
|
||||||
}
|
}
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const jsonData = await response.json();
|
const jsonData = await response.json();
|
||||||
@@ -114,6 +114,7 @@ const Search = (
|
|||||||
const searchQuery = useSignal(q);
|
const searchQuery = useSignal(q);
|
||||||
const data = useSignal<GenericResource[] | undefined>(results);
|
const data = useSignal<GenericResource[] | undefined>(results);
|
||||||
const isLoading = useSignal(false);
|
const isLoading = useSignal(false);
|
||||||
|
const rating = useSignal<number | undefined>(undefined);
|
||||||
const showSeenStatus = useSignal(false);
|
const showSeenStatus = useSignal(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -122,8 +123,10 @@ const Search = (
|
|||||||
if (u.searchParams.get("q") !== searchQuery.value) {
|
if (u.searchParams.get("q") !== searchQuery.value) {
|
||||||
u.searchParams.set("q", searchQuery.value);
|
u.searchParams.set("q", searchQuery.value);
|
||||||
}
|
}
|
||||||
if (showSeenStatus.value) {
|
if (showSeenStatus.value === true) {
|
||||||
u.searchParams.set("rating", "0");
|
u.searchParams.set("rating", "0");
|
||||||
|
} else if (rating.value) {
|
||||||
|
u.searchParams.set("rating", rating.value.toString());
|
||||||
} else {
|
} else {
|
||||||
u.searchParams.delete("rating");
|
u.searchParams.delete("rating");
|
||||||
}
|
}
|
||||||
@@ -162,7 +165,7 @@ const Search = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debouncedFetchData(); // Call the debounced fetch function with the updated search query
|
debouncedFetchData(); // Call the debounced fetch function with the updated search query
|
||||||
}, [searchQuery.value, showSeenStatus.value]);
|
}, [searchQuery.value, showSeenStatus.value, rating.value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debouncedFetchData();
|
debouncedFetchData();
|
||||||
@@ -187,8 +190,12 @@ const Search = (
|
|||||||
onInput={handleInputChange}
|
onInput={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox label="seen" checked={showSeenStatus} />
|
<Checkbox label="unseen" checked={showSeenStatus} />
|
||||||
<Rating rating={4} />
|
<div
|
||||||
|
class={showSeenStatus.value ? "opacity-10" : ""}
|
||||||
|
>
|
||||||
|
<Rating rating={rating} />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{data.value?.length && !isLoading.value
|
{data.value?.length && !isLoading.value
|
||||||
? <SearchResultList showEmoji={!type} result={data.value} />
|
? <SearchResultList showEmoji={!type} result={data.value} />
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ const cacheLock = new Map<string, Promise<Resource>>();
|
|||||||
|
|
||||||
async function fetchAndStoreUrl(url: string) {
|
async function fetchAndStoreUrl(url: string) {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch resource: ${response.status}`);
|
||||||
|
}
|
||||||
const res = await response.json();
|
const res = await response.json();
|
||||||
fetchCache.set(url, res);
|
fetchCache.set(url, res);
|
||||||
return res;
|
return res;
|
||||||
@@ -54,7 +57,7 @@ async function cachedFetch(
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchResource<T extends Resource>(
|
export async function fetchResource<T extends GenericResource>(
|
||||||
resource: string,
|
resource: string,
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
@@ -80,6 +83,7 @@ export async function listResources<T extends GenericResource>(
|
|||||||
.map((res) => addImageToResource(res) as Promise<T>),
|
.map((res) => addImageToResource(res) as Promise<T>),
|
||||||
);
|
);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
|
console.log(`Failed to fetch resource: ${resource}`, _e);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function parseResourceUrl(_url: string | URL): SearchParams | undefined {
|
|||||||
try {
|
try {
|
||||||
const url = typeof _url === "string" ? new URL(_url) : _url;
|
const url = typeof _url === "string" ? new URL(_url) : _url;
|
||||||
let query = url.searchParams.get("q") || "*";
|
let query = url.searchParams.get("q") || "*";
|
||||||
if (!query) {
|
if (!(typeof query === "string")) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
query = decodeURIComponent(query);
|
query = decodeURIComponent(query);
|
||||||
@@ -54,12 +54,14 @@ export async function searchResource(
|
|||||||
{ q, tags = [], types, rating }: SearchParams,
|
{ q, tags = [], types, rating }: SearchParams,
|
||||||
): Promise<GenericResource[]> {
|
): Promise<GenericResource[]> {
|
||||||
const resources = (await Promise.all([
|
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("series")) && listResources("series"),
|
||||||
(!types || types.includes("article")) && listResources("articles"),
|
(!types || types.includes("articles")) && listResources("articles"),
|
||||||
(!types || types.includes("recipe")) && listResources("recipes"),
|
(!types || types.includes("recipes")) && listResources("recipes"),
|
||||||
])).flat().filter(isResource);
|
])).flat().filter(isResource);
|
||||||
|
|
||||||
|
console.log({ types, rating, tags, q });
|
||||||
|
|
||||||
const results: Record<string, GenericResource> = {};
|
const results: Record<string, GenericResource> = {};
|
||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
@@ -71,9 +73,18 @@ export async function searchResource(
|
|||||||
results[resource.name] = resource;
|
results[resource.name] = resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Select not-rated resources
|
||||||
|
if (
|
||||||
|
rating === 0 &&
|
||||||
|
resource.content?.reviewRating?.ratingValue === undefined
|
||||||
|
) {
|
||||||
|
results[resource.name] = resource;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!(resource.name in results) &&
|
!(resource.name in results) &&
|
||||||
rating && resource.content.reviewRating?.ratingValue &&
|
typeof rating == "number" &&
|
||||||
|
resource.content.reviewRating?.ratingValue &&
|
||||||
parseRating(resource.content.reviewRating.ratingValue) >= rating
|
parseRating(resource.content.reviewRating.ratingValue) >= rating
|
||||||
) {
|
) {
|
||||||
results[resource.name] = resource;
|
results[resource.name] = resource;
|
||||||
@@ -81,6 +92,7 @@ export async function searchResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (q.length && q !== "*") {
|
if (q.length && q !== "*") {
|
||||||
|
q = decodeURIComponent(q);
|
||||||
const fuzzyResult = fuzzysort.go(q, resources, {
|
const fuzzyResult = fuzzysort.go(q, resources, {
|
||||||
keys: [
|
keys: [
|
||||||
"name",
|
"name",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function safeFileName(input: string): string {
|
|||||||
.normalize("NFKD")
|
.normalize("NFKD")
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
.replace(/[\s-]+/g, "_")
|
.replace(/[\s-]+/g, "_")
|
||||||
.replace(/[^A-Za-z0-9._]+/g, "")
|
.replace(/[^A-Za-z0-9_]+/g, "")
|
||||||
.replace(/_+/g, "_")
|
.replace(/_+/g, "_")
|
||||||
// Trim underscores/dots from ends and prevent leading dots
|
// Trim underscores/dots from ends and prevent leading dots
|
||||||
.replace(/^[_\.]+|[_\.]+$/g, "").replace(/^\.+/, "")
|
.replace(/^[_\.]+|[_\.]+$/g, "").replace(/^\.+/, "")
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ export interface PageInfo {
|
|||||||
resultsPerPage: number;
|
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(
|
export async function getYoutubeVideoDetails(
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<Item> {
|
): Promise<Item> {
|
||||||
@@ -81,6 +86,5 @@ export async function getYoutubeVideoDetails(
|
|||||||
`${BASE_URL}videos?part=snippet%2CcontentDetails%2Cstatistics&id=${id}&key=${YOUTUBE_API_KEY}`,
|
`${BASE_URL}videos?part=snippet%2CcontentDetails%2Cstatistics&id=${id}&key=${YOUTUBE_API_KEY}`,
|
||||||
);
|
);
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
|
|
||||||
return json?.items[0];
|
return json?.items[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,32 @@ import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts"
|
|||||||
|
|
||||||
const log = createLogger("api/article");
|
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(
|
async function processCreateArticle(
|
||||||
{ fetchUrl, streamResponse }: {
|
{ fetchUrl, streamResponse }: {
|
||||||
fetchUrl: string;
|
fetchUrl: string;
|
||||||
@@ -49,23 +75,11 @@ async function processCreateArticle(
|
|||||||
|
|
||||||
const title = result?.title || aiMeta?.headline || "";
|
const title = result?.title || aiMeta?.headline || "";
|
||||||
|
|
||||||
let finalPath = result.image;
|
const coverImagePath = await fetchAndStoreCover(
|
||||||
if (result?.image) {
|
result.image,
|
||||||
const extension = fileExtension(result?.image);
|
title,
|
||||||
const imagePath = `articles/images/${
|
streamResponse,
|
||||||
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 newArticle: ArticleResource["content"] = {
|
const newArticle: ArticleResource["content"] = {
|
||||||
_type: "Article",
|
_type: "Article",
|
||||||
@@ -75,7 +89,7 @@ async function processCreateArticle(
|
|||||||
datePublished: formatDate(
|
datePublished: formatDate(
|
||||||
result?.published || aiMeta?.datePublished || undefined,
|
result?.published || aiMeta?.datePublished || undefined,
|
||||||
),
|
),
|
||||||
image: finalPath,
|
image: coverImagePath,
|
||||||
author: {
|
author: {
|
||||||
_type: "Person",
|
_type: "Person",
|
||||||
name: (result.schemaOrgData?.author?.name || aiMeta?.author || "")
|
name: (result.schemaOrgData?.author?.name || aiMeta?.author || "")
|
||||||
@@ -115,16 +129,23 @@ async function processCreateYoutubeVideo(
|
|||||||
const video = await getYoutubeVideoDetails(youtubeId);
|
const video = await getYoutubeVideoDetails(youtubeId);
|
||||||
|
|
||||||
streamResponse.enqueue("shortening title with openai");
|
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"] = {
|
const newArticle: ArticleResource["content"] = {
|
||||||
_type: "Article",
|
_type: "Article",
|
||||||
headline: video.snippet.title,
|
headline: video.snippet.title,
|
||||||
articleBody: video.snippet.description,
|
articleBody: video.snippet.description,
|
||||||
|
image: coverImagePath,
|
||||||
url: fetchUrl,
|
url: fetchUrl,
|
||||||
datePublished: new Date(video.snippet.publishedAt).toISOString(),
|
datePublished: formatDate(video.snippet.publishedAt),
|
||||||
author: {
|
author: {
|
||||||
_type: "Person",
|
_type: "Person",
|
||||||
name: video.snippet.channelTitle,
|
name: video.snippet.channelTitle,
|
||||||
@@ -133,11 +154,14 @@ async function processCreateYoutubeVideo(
|
|||||||
|
|
||||||
streamResponse.enqueue("creating article");
|
streamResponse.enqueue("creating article");
|
||||||
|
|
||||||
await createResource(`articles/${id}.md`, newArticle);
|
await createResource(
|
||||||
|
`articles/${toUrlSafeString(videoTitle)}.md`,
|
||||||
|
newArticle,
|
||||||
|
);
|
||||||
|
|
||||||
streamResponse.enqueue("finished");
|
streamResponse.enqueue("finished");
|
||||||
|
|
||||||
streamResponse.enqueue("id: " + id);
|
streamResponse.enqueue("id: " + toUrlSafeString(videoTitle));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handler: Handlers = {
|
export const handler: Handlers = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Handlers } from "$fresh/server.ts";
|
import { Handlers } from "$fresh/server.ts";
|
||||||
import { json } from "@lib/helpers.ts";
|
import { json } from "@lib/helpers.ts";
|
||||||
import { AccessDeniedError } from "@lib/errors.ts";
|
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
|
||||||
import { searchResource } from "@lib/search.ts";
|
import { parseResourceUrl, searchResource } from "@lib/search.ts";
|
||||||
|
|
||||||
export const handler: Handlers = {
|
export const handler: Handlers = {
|
||||||
async GET(req, ctx) {
|
async GET(req, ctx) {
|
||||||
@@ -10,18 +10,13 @@ export const handler: Handlers = {
|
|||||||
throw new AccessDeniedError();
|
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(",");
|
console.log(s);
|
||||||
const tags = url.searchParams?.get("tags")?.split(",");
|
const resources = await searchResource(s);
|
||||||
const authors = url.searchParams?.get("authors")?.split(",");
|
|
||||||
|
|
||||||
const resources = await searchResource({
|
|
||||||
q: url.searchParams.get("q") || "",
|
|
||||||
types,
|
|
||||||
tags,
|
|
||||||
authors,
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(resources);
|
return json(resources);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user