Compare commits

...

2 Commits

Author SHA1 Message Date
Max Richter
21841b4dc4 fix: use correct url 2025-11-02 19:03:34 +01:00
Max Richter
e6b90cb785 feat: refactor whole bunch of stuff 2025-11-02 19:03:11 +01:00
56 changed files with 753 additions and 360 deletions

View File

@@ -11,13 +11,13 @@ export function Card(
rating,
title,
image,
thumbnail,
thumbhash,
backgroundColor,
backgroundSize = 100,
}: {
backgroundSize?: number;
backgroundColor?: string;
thumbnail?: string;
thumbhash?: string;
link?: string;
title?: string;
image?: string;
@@ -39,22 +39,20 @@ export function Card(
<Link
href={link}
style={backgroundStyle}
data-thumb={thumbnail}
data-thumb={thumbhash}
class="text-white rounded-3xl shadow-md relative
lg:w-56 lg:h-56
sm:w-48 sm:h-48
w-[37vw] h-[37vw]"
>
{true && (
<span class="absolute top-0 left-0 overflow-hidden rounded-3xl w-full h-full">
<img
class="w-full h-full object-cover"
data-thumb-img
loading="lazy"
src={image || "/placeholder.svg"}
/>
</span>
)}
<span class="absolute top-0 left-0 overflow-hidden rounded-3xl w-full h-full">
<img
class="w-full h-full object-cover"
data-thumb-img
loading="lazy"
src={image || "/placeholder.svg"}
/>
</span>
<div
class="p-4 flex flex-col justify-between relative z-10"
style={{
@@ -97,7 +95,7 @@ export function Card(
export function ResourceCard(
{ res, sublink = "movies" }: { sublink?: string; res: GenericResource },
) {
const img = res?.content?.image || res?.content?.cover;
const img = res?.image?.url;
const imageUrl = img
? `/api/images?image=${img}&width=200&height=200`
@@ -105,11 +103,12 @@ export function ResourceCard(
return (
<Card
title={res.content?.name || res.content?.itemReviewed?.name || res.content?.headline ||
title={res.content?.name || res.content?.itemReviewed?.name ||
res.content?.headline ||
res?.name}
backgroundColor={res.meta?.average}
backgroundColor={res.image?.average}
thumbhash={res.image?.blurhash}
rating={parseRating(res.content?.reviewRating?.ratingValue)}
thumbnail={res.cover}
image={imageUrl}
link={`/${sublink}/${res.name.replace(/\.md$/g, "")}`}
/>

View File

@@ -34,7 +34,7 @@ const Image = (
class: string;
src: string;
alt?: string;
thumbnail?: string;
thumbhash?: string;
fill?: boolean;
width?: number | string;
height?: number | string;
@@ -55,19 +55,17 @@ const Image = (
height: props.fill ? "100%" : "",
zIndex: props.fill ? -1 : "",
}}
data-thumb={props.thumbnail}
data-thumb={props.thumbhash}
>
<img
data-thumb={props.thumbnail}
data-thumb-img
loading="lazy"
alt={props.alt}
style={props.style}
srcset={responsiveAttributes.srcset}
sizes={responsiveAttributes.sizes}
src={`/api/images?image=${asset(props.src)}${
props.width ? `&width=${props.width}` : ""
}${props.height ? `&height=${props.height}` : ""}`}
src={`/api/images?image=${asset(props.src)}${props.width ? `&width=${props.width}` : ""
}${props.height ? `&height=${props.height}` : ""}`}
width={props.width}
height={props.height}
class={props.class}

View File

@@ -6,23 +6,22 @@ import { IconArrowNarrowLeft } from "@components/icons.tsx";
import { IconEdit } from "@components/icons.tsx";
import { useContext } from "preact/hooks";
const HeroContext = createContext<{ image?: string; thumbnail?: string }>({
const HeroContext = createContext<{ image?: string; thumbhash?: string }>({
image: undefined,
thumbnail: undefined,
thumbhash: undefined,
});
function Wrapper(
{ children, image, thumbnail }: {
{ children, image, thumbhash }: {
children: ComponentChildren;
image?: string;
thumbnail?: string;
thumbhash?: string;
},
) {
return (
<div
class={`flex justify-between flex-col relative w-full ${
image ? "min-h-[400px]" : "min-h-[200px]"
} rounded-3xl overflow-hidden`}
class={`flex justify-between flex-col relative w-full ${image ? "min-h-[400px]" : "min-h-[200px]"
} rounded-3xl overflow-hidden`}
>
<HeroContext.Provider value={{ image }}>
{image &&
@@ -30,7 +29,7 @@ function Wrapper(
<Image
fill
src={image}
thumbnail={thumbnail}
thumbhash={thumbhash}
alt="Recipe Banner"
// style={{ objectPosition: "0% 25%" }}
class="absolute object-cover w-full h-full -z-10"
@@ -52,9 +51,8 @@ function Title(
return (
<OuterTag
href={link}
class={`${
ctx.image ? "noisy-gradient" : ""
} after:opacity-90 flex gap-4 items-center ${ctx.image ? "pt-12" : ""}`}
class={`${ctx.image ? "noisy-gradient" : ""
} after:opacity-90 flex gap-4 items-center ${ctx.image ? "pt-12" : ""}`}
>
<h2
class="flex gap-2 items-center text-4xl font-bold z-10"
@@ -83,9 +81,8 @@ function EditLink({ href }: { href: string }) {
const ctx = useContext(HeroContext);
return (
<a
class={`px-4 py-2 ${
ctx.image ? "bg-gray-300 text-gray-800" : "text-gray-200"
} rounded-lg flex gap-1 items-center`}
class={`px-4 py-2 ${ctx.image ? "bg-gray-300 text-gray-800" : "text-gray-200"
} rounded-lg flex gap-1 items-center`}
href={href}
>
<IconEdit class="w-5 h-5" />

View File

@@ -0,0 +1 @@
ALTER TABLE `image` RENAME COLUMN "blurhash" TO "thumbhash";

View File

@@ -0,0 +1,311 @@
{
"version": "6",
"dialect": "sqlite",
"id": "685b57ca-45e0-4373-baee-fc3abb4f2d74",
"prevId": "5694a345-e55c-4aa3-9f29-1045b28f5203",
"tables": {
"cache": {
"name": "cache",
"columns": {
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"json": {
"name": "json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"binary": {
"name": "binary",
"type": "blob",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"key_idx": {
"name": "key_idx",
"columns": [
"key"
],
"isUnique": false
},
"scope_idx": {
"name": "scope_idx",
"columns": [
"scope"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"document": {
"name": "document",
"columns": {
"name": {
"name": "name",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"last_modified": {
"name": "last_modified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"perm": {
"name": "perm",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"name_idx": {
"name": "name_idx",
"columns": [
"name"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"image": {
"name": "image",
"columns": {
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"average": {
"name": "average",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbhash": {
"name": "thumbhash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime": {
"name": "mime",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"performance": {
"name": "performance",
"columns": {
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"search": {
"name": "search",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(STRFTIME('%s', 'now') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(current_timestamp)"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"image\".\"blurhash\"": "\"image\".\"thumbhash\""
}
},
"internal": {
"indexes": {}
}
}

View File

@@ -71,6 +71,13 @@
"when": 1736172911816,
"tag": "0009_free_robin_chapel",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1762099260474,
"tag": "0010_youthful_tyrannus",
"breakpoints": true
}
]
}

View File

@@ -2,7 +2,7 @@ import { Signal } from "@preact/signals";
import type { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts";
import { FunctionalComponent } from "preact";
import { unitsOfMeasure } from "@lib/parseIngredient.ts";
import { renderMarkdown } from "@lib/documents.ts";
import { renderMarkdown } from "@lib/markdown.ts";
function formatAmount(num: number) {
if (num === 0) return "";

View File

@@ -1,7 +1,7 @@
import { Movie } from "@lib/resource/movies.ts";
import { TMDBMovie } from "@lib/types.ts";
import { getCookie } from "@lib/string.ts";
import { MenuEntry } from "../types.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const addMovieInfos: MenuEntry = {
title: "Add Movie infos",
@@ -9,7 +9,7 @@ export const addMovieInfos: MenuEntry = {
icon: "IconReportSearch",
cb: async (state, context) => {
state.activeState.value = "loading";
const movie = context as Movie;
const movie = context as ReviewResource;
const query = movie.name;
@@ -33,7 +33,7 @@ export const addMovieInfos: MenuEntry = {
});
state.visible.value = false;
state.activeState.value = "normal";
window.location.reload();
globalThis.location.reload();
},
})),
};

View File

@@ -1,7 +1,7 @@
import { MenuEntry } from "@islands/KMenu/types.ts";
import { TMDBSeries } from "@lib/types.ts";
import { getCookie } from "@lib/string.ts";
import { Series } from "@lib/resource/series.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const addSeriesInfo: MenuEntry = {
title: "Add Series infos",
@@ -9,7 +9,7 @@ export const addSeriesInfo: MenuEntry = {
icon: "IconReportSearch",
cb: async (state, context) => {
state.activeState.value = "loading";
const series = context as Series;
const series = context as ReviewResource;
const query = series.name;

View File

@@ -1,8 +1,8 @@
import { MenuEntry } from "@islands/KMenu/types.ts";
import { TMDBMovie } from "@lib/types.ts";
import { debounce } from "@lib/helpers.ts";
import { Movie } from "@lib/resource/movies.ts";
import { getCookie } from "@lib/string.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const createNewMovie: MenuEntry = {
title: "Create new movie",
@@ -52,9 +52,9 @@ export const createNewMovie: MenuEntry = {
const response = await fetch("/api/movies/" + r.id, {
method: "POST",
});
const movie = await response.json() as Movie;
const movie = await response.json() as ReviewResource;
unsub();
window.location.href = "/movies/" + movie.name;
globalThis.location.href = "/movies/" + movie.name;
},
};
}),

View File

@@ -1,8 +1,8 @@
import { MenuEntry } from "@islands/KMenu/types.ts";
import { TMDBSeries } from "@lib/types.ts";
import { debounce } from "@lib/helpers.ts";
import { Series } from "@lib/resource/series.ts";
import { getCookie } from "@lib/string.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const createNewSeries: MenuEntry = {
title: "Create new series",
@@ -54,9 +54,9 @@ export const createNewSeries: MenuEntry = {
const response = await fetch("/api/series/" + r.id, {
method: "POST",
});
const series = await response.json() as Series;
const series = await response.json() as ReviewResource;
unsub();
window.location.href = "/series/" + series.name;
globalThis.location.href = "/series/" + series.name;
},
};
}),

View File

@@ -6,13 +6,16 @@ declare global {
}
export function Link(
{ href, children, class: _class, style }: {
props: {
href?: string;
class?: string;
style?: preact.JSX.CSSProperties;
children: preact.ComponentChildren;
"data-thumb"?: string;
},
) {
const { href, children, class: _class, style } = props;
const thumbhash = props["data-thumb"];
function handleClick() {
if (globalThis.loadingTimeout) {
return;
@@ -41,6 +44,7 @@ export function Link(
href={href}
style={style}
onClick={handleClick}
data-thumb={thumbhash}
class={_class}
>
{children}

View File

@@ -2,7 +2,6 @@ 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";
@@ -11,6 +10,7 @@ import { Rating } from "@components/Rating.tsx";
import { useSignal } from "@preact/signals";
import Image from "@components/Image.tsx";
import { Emoji } from "@components/Emoji.tsx";
import { GenericResource } from "@lib/marka/schema.ts";
export async function fetchQueryResource(url: URL, type = "") {
const query = url.searchParams.get("q");
@@ -46,7 +46,7 @@ export const RedirectSearchHandler = () => {
}, IS_BROWSER ? document?.body : undefined);
}
return <></>;
return;
};
const SearchResultImage = ({ src }: { src: string }) => {
@@ -67,8 +67,9 @@ export const SearchResultItem = (
showEmoji?: boolean;
},
) => {
const resourceType = resources[item.type];
const href = resourceType ? `${resourceType.link}/${item.id}` : "";
const resourceType = resources[item?.content._type];
const href = item?.path.replace("/resources", "").replace(/\.md$/, "");
console.log({ item, href });
return (
<a
href={href}
@@ -77,8 +78,9 @@ export const SearchResultItem = (
{showEmoji && resourceType
? <Emoji class="w-7 h-7" name={resourceType.emoji} />
: ""}
{item.meta?.image && <SearchResultImage src={item.meta?.image} />}
{item?.name}
{item.image && <SearchResultImage src={item.image?.url} />}
{item.content?.headline || item.content?.name ||
item.content?.itemReviewed.name || item?.name}
</a>
);
};
@@ -190,16 +192,16 @@ const Search = (
{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 />
: (
<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>
);
};

View File

@@ -47,7 +47,7 @@ export const imageTable = sqliteTable("image", {
),
url: text().notNull(),
average: text().notNull(),
blurhash: text().notNull(),
thumbhash: text().notNull(),
mime: text().notNull(),
});

View File

@@ -104,12 +104,13 @@ export function debounce<T extends (...args: Parameters<T>) => void>(
export function parseRating(rating: string | number) {
if (typeof rating === "string") {
try {
return parseInt(rating);
const res = parseInt(rating);
if (!Number.isNaN(res)) return res;
} catch (_e) {
// This is okay
}
return [...rating.matchAll(/⭐/g)].length;
return rating.length / 2;
}
return rating;
}

View File

@@ -3,7 +3,7 @@ import { createLogger } from "@lib/log/index.ts";
import { generateThumbhash } from "@lib/thumbhash.ts";
import { parseMediaType } from "https://deno.land/std@0.224.0/media_types/parse_media_type.ts";
import path from "node:path";
import { ensureDir } from "fs";
import { mkdir } from "node:fs/promises";
import { DATA_DIR } from "@lib/env.ts";
import { db } from "@lib/db/sqlite.ts";
import { imageTable } from "@lib/db/schema.ts";
@@ -13,7 +13,7 @@ import sharp from "npm:sharp@next";
const log = createLogger("cache/image");
const imageDir = path.join(DATA_DIR, "images");
await ensureDir(imageDir);
await mkdir(imageDir, { recursive: true });
async function getRemoteImage(imageUrl: string) {
try {
@@ -100,7 +100,7 @@ async function getLocalImagePath(
hostname,
pathname.split("/").filter((s) => s.length).join("-"),
);
await ensureDir(imagePath);
await mkdir(imagePath, { recursive: true });
if (width || height) {
imagePath = path.join(imagePath, `${width ?? "-"}x${height ?? "-"}`);
@@ -313,7 +313,7 @@ export async function getImage(url: string) {
// Store in database
const [newImage] = await db.insert(imageTable).values({
url: url,
blurhash: thumbhash.hash,
thumbhash: thumbhash.hash,
average: thumbhash.average,
mime: imageContent.mediaType,
}).returning();

View File

@@ -1,12 +1,12 @@
import * as env from "@lib/env.ts";
import { ensureDir } from "fs";
import { join } from "node:path";
import { getLogLevel, LOG_LEVEL } from "@lib/log/types.ts";
import { mkdir } from "node:fs/promises";
export const LOG_DIR = join(env.DATA_DIR, "logs");
// Ensure the log directory exists
await ensureDir(LOG_DIR);
await mkdir(LOG_DIR, { recursive: true });
export let logLevel = getLogLevel(env.LOG_LEVEL);
export function setLogLevel(level: LOG_LEVEL) {

View File

@@ -1,35 +0,0 @@
import { MARKA_API_KEY } from "./env.ts";
const url = `https://marka.max-richter.dev/resources`;
//const url = "http://localhost:8080/resources";
export async function fetchResource(resource: string) {
try {
const response = await fetch(
`${url}/${resource}`,
);
return response.json();
} catch (_e) {
return [];
}
}
export async function createResource(
path: string,
content: string | object | ArrayBuffer,
) {
const isJson = typeof content === "object" &&
!(content instanceof ArrayBuffer);
const fetchUrl = `${url}/${path}`;
const response = await fetch(fetchUrl, {
method: "POST",
headers: {
"Content-Type": isJson ? "application/json" : "",
"Authentication": MARKA_API_KEY,
},
body: isJson ? JSON.stringify(content) : content,
});
if (!response.ok) {
throw new Error(`Failed to create resource: ${response.status}`);
}
return response.json();
}

80
lib/marka/index.ts Normal file
View File

@@ -0,0 +1,80 @@
import { MARKA_API_KEY } from "../env.ts";
import { getImage } from "../image.ts";
import { GenericResource } from "./schema.ts";
const url = `https://marka.max-richter.dev`;
//const url = "http://localhost:8080";
async function addImageToResource<T extends GenericResource>(
resource: GenericResource,
): Promise<T> {
const imageUrl = resource?.content?.image;
if (imageUrl) {
try {
const absoluteImageUrl = (imageUrl.startsWith("https://") ||
imageUrl.startsWith("http://"))
? imageUrl
: `${url}/${imageUrl}`;
const image = await getImage(absoluteImageUrl);
return { ...resource, image } as T;
} catch (e) {
console.log(`Failed to fetch image: ${imageUrl}`, e);
}
}
return resource as T;
}
export async function fetchResource<T extends GenericResource>(
resource: string,
): Promise<T | undefined> {
try {
const response = await fetch(
`${url}/resources/${resource}`,
);
const res = await response.json();
return addImageToResource<T>(res);
} catch (_e) {
return;
}
}
export async function listResources<T = GenericResource>(
resource: string,
): Promise<T[]> {
try {
const response = await fetch(
`${url}/resources/${resource}`,
);
const list = await response.json();
return Promise.all(
list?.content
.filter((a: GenericResource) => a?.content?._type)
.map((res: GenericResource) => addImageToResource(res)),
);
} catch (_e) {
return [];
}
}
export async function createResource(
path: string,
content: string | object | ArrayBuffer,
) {
const isJson = typeof content === "object" &&
!(content instanceof ArrayBuffer);
const fetchUrl = `${url}/resources/${path}`;
const headers = new Headers();
headers.append("Content-Type", isJson ? "application/json" : "");
if (MARKA_API_KEY) {
headers.append("Authentication", MARKA_API_KEY);
}
const response = await fetch(fetchUrl, {
method: "POST",
headers,
body: isJson ? JSON.stringify(content) : content,
});
if (!response.ok) {
throw new Error(`Failed to create resource: ${response.status}`);
}
return response.json();
}

126
lib/marka/schema.ts Normal file
View File

@@ -0,0 +1,126 @@
import { z } from "zod";
import { imageTable } from "../db/schema.ts";
export const PersonSchema = z.object({
_type: z.literal("Person"),
name: z.string().optional(),
});
export const ReviewRatingSchema = z.object({
bestRating: z.number().optional(),
worstRating: z.number().optional(),
// Accept number or string (e.g., "⭐️⭐️⭐️⭐️⭐️")
ratingValue: z.union([z.number(), z.string()]).optional(),
});
const WithAuthor = z.object({ author: PersonSchema.optional() });
const WithKeywords = z.object({ keywords: z.array(z.string()).optional() });
const WithImage = z.object({ image: z.string().optional() });
const WithDatePublished = z.object({ datePublished: z.string().optional() });
const BaseContent = WithAuthor.merge(WithKeywords)
.merge(WithImage)
.merge(WithDatePublished);
export const BaseFileSchema = z.object({
type: z.literal("file"),
name: z.string(),
path: z.string(),
modTime: z.string(), // ISO timestamp string
mime: z.string(),
size: z.number().int().nonnegative(),
});
const makeContentSchema = <
TName extends "Article" | "Review" | "Recipe",
TShape extends z.ZodRawShape,
>(
name: TName,
shape: TShape,
) =>
z
.object({
_type: z.literal(name),
keywords: z.array(z.string()).optional(),
})
.merge(BaseContent)
.extend(shape);
export const ArticleContentSchema = makeContentSchema("Article", {
headline: z.string().optional(),
articleBody: z.string().optional(),
url: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
});
export const ReviewContentSchema = makeContentSchema("Review", {
tmdbId: z.number().optional(),
link: z.string().optional(),
reviewRating: ReviewRatingSchema.optional(),
reviewBody: z.string().optional(),
itemReviewed: z
.object({
name: z.string().optional(),
})
.optional(),
});
export const RecipeContentSchema = makeContentSchema("Recipe", {
description: z.string().optional(),
name: z.string().optional(),
recipeIngredient: z.array(z.string()).optional(),
recipeInstructions: z.array(z.string()).optional(),
totalTime: z.string().optional(),
recipeYield: z.number().optional(),
url: z.string().optional(),
});
export const articleMetadataSchema = z.object({
headline: z.union([z.null(), z.string()]).describe("Headline of the article"),
author: z.union([z.null(), z.string()]).describe("Author of the article"),
datePublished: z.union([z.null(), z.string()]).describe(
"Date the article was published",
),
keywords: z.union([z.null(), z.array(z.string())]).describe(
"Keywords for the article",
),
});
export const ArticleSchema = BaseFileSchema.extend({
content: ArticleContentSchema,
});
export const ReviewSchema = BaseFileSchema.extend({
content: ReviewContentSchema,
});
export const RecipeSchema = BaseFileSchema.extend({
content: RecipeContentSchema,
});
export const GenericResourceSchema = z.union([
ArticleSchema,
ReviewSchema,
RecipeSchema,
]);
export type Person = z.infer<typeof PersonSchema>;
export type ReviewRating = z.infer<typeof ReviewRatingSchema>;
export type BaseFile = z.infer<typeof BaseFileSchema>;
export type ArticleResource = z.infer<typeof ArticleSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type ReviewResource = z.infer<typeof ReviewSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type RecipeResource = z.infer<typeof RecipeSchema> & {
image?: typeof imageTable.$inferSelect;
};
export type GenericResource = z.infer<typeof GenericResourceSchema> & {
image?: typeof imageTable.$inferSelect;
};

View File

@@ -44,11 +44,3 @@ export function renderMarkdown(doc: string) {
allowMath: true,
});
}
export function createDocument(
path: string,
entry: string,
mimetype = "image/jpeg",
) {
console.log("creating", { path, entry, mimetype });
}

View File

@@ -4,7 +4,7 @@ import { OPENAI_API_KEY } from "@lib/env.ts";
import { hashString } from "@lib/helpers.ts";
import { createCache } from "@lib/cache.ts";
import { recipeResponseSchema } from "@lib/recipeSchema.ts";
import { articleMetadataSchema } from "./resource/articles.ts";
import { articleMetadataSchema } from "./marka/schema.ts";
const openAI = OPENAI_API_KEY && new OpenAI({ apiKey: OPENAI_API_KEY });

View File

@@ -1,4 +1,5 @@
import { z } from "npm:zod";
import { RecipeResource } from "./marka/schema.ts";
export const IngredientSchema = z.object({
quantity: z.string().describe(
@@ -17,7 +18,6 @@ export const IngredientGroupSchema = z.object({
export type IngredientGroup = z.infer<typeof IngredientGroupSchema>;
const recipeSchema = z.object({
_type: z.literal("Recipe"),
name: z.string().describe(
"Title of the Recipe, without the name of the website or author",
),
@@ -29,6 +29,9 @@ const recipeSchema = z.object({
_type: z.literal("Person"),
name: z.string().describe("author of the Recipe (optional)"),
}),
keywords: z.array(z.string()).describe(
"List of keywords that match the recipe",
),
recipeIngredient: z.array(z.string())
.describe("List of ingredients"),
recipeInstructions: z.array(z.string()).describe("List of instructions"),
@@ -48,7 +51,7 @@ export const recipeResponseSchema = z.union([recipeSchema, noRecipeSchema]);
export function isValidRecipe(
recipe:
| Recipe
| RecipeResource
| null
| undefined,
) {

View File

@@ -1,8 +1,8 @@
import * as openai from "@lib/openai.ts";
import * as tmdb from "@lib/tmdb.ts";
import { GenericResource } from "@lib/types.ts";
import { parseRating } from "@lib/helpers.ts";
import { createCache } from "@lib/cache.ts";
import { GenericResource, ReviewResource } from "./marka/schema.ts";
type RecommendationResource = {
id: string;
@@ -18,42 +18,48 @@ type RecommendationResource = {
const cache = createCache<RecommendationResource>("recommendations");
export async function createRecommendationResource(
res: GenericResource,
res: ReviewResource,
description?: string,
) {
const cacheId = `${res.type}:${res.id.replaceAll(":", "")}`;
const cacheId = `${res.type}:${res.name.replaceAll(":", "")}`;
const resource = cache.get(cacheId) || {
id: res.id,
id: res.name,
type: res.type,
rating: -1,
};
if (description && !resource.keywords) {
const keywords = await openai.createKeywords(res.type, description, res.id);
const keywords = await openai.createKeywords(
res.type,
description,
res.name,
);
if (keywords?.length) {
resource.keywords = keywords;
}
}
const { author, date, rating } = res.meta || {};
const { author, datePublished, reviewRating } = res.content;
if (res?.tags) {
resource.tags = res.tags;
if (res?.content?.keywords) {
resource.keywords = res.content.keywords;
}
if (typeof rating !== "undefined") {
resource.rating = parseRating(rating);
if (typeof reviewRating?.ratingValue !== "undefined") {
resource.rating = parseRating(reviewRating?.ratingValue);
}
if (author) {
resource.author = author;
if (author?.name) {
resource.author = author.name;
}
if (description) {
resource.description = description;
}
if (date) {
const d = typeof date === "string" ? new Date(date) : date;
if (datePublished) {
const d = typeof datePublished === "string"
? new Date(datePublished)
: datePublished;
resource.year = d.getFullYear();
}

View File

@@ -1,30 +0,0 @@
import { z } from "zod";
export type Article = {
_type: "Article";
headline?: string;
datePublished?: string;
articleBody?: string;
keywords?: string[];
image?: string;
url?: string;
reviewRating?: {
bestRating?: number;
worstRating?: number;
ratingValue?: number;
};
author?: {
_type: "Person";
name?: string;
};
};
export const articleMetadataSchema = z.object({
headline: z.union([z.null(), z.string()]).describe("Headline of the article"),
author: z.union([z.null(), z.string()]).describe("Author of the article"),
datePublished: z.union([z.null(), z.string()]).describe(
"Date the article was published",
),
keywords: z.union([z.null(), z.array(z.string())]).describe(
"Keywords for the article",
),
});

View File

@@ -1,21 +0,0 @@
export type Movie = {
_type: "Review";
tmdbId?: number;
link?: string;
author?: {
_type: "Person";
name?: string;
};
datePublished?: string;
reviewRating?: {
bestRating?: number;
worstRating?: number;
ratingValue?: number;
};
reviewBody?: string;
itemReviewed?: {
name?: string;
};
keywords?: string[];
image?: string;
};

View File

@@ -1,17 +0,0 @@
export type Recipe = {
_type: "Recipe";
author?: {
_type: "Person";
name?: string;
};
description?: string;
image?: string;
name?: string;
recipeIngredient?: string[];
recipeInstructions?: string[];
datePublished?: string;
totalTime?: string;
recipeYield?: number;
url?: string;
keywords?: string[];
};

View File

@@ -1,3 +0,0 @@
import { Movie } from "./movies.ts";
export type Series = Movie;

View File

@@ -1,12 +1,8 @@
import { resources } from "@lib/resources.ts";
import fuzzysort from "npm:fuzzysort";
import { GenericResource } from "@lib/types.ts";
import { extractHashTags } from "@lib/string.ts";
import { Movie } from "@lib/resource/movies.ts";
import { Article } from "@lib/resource/articles.ts";
import { Recipe } from "@lib/resource/recipes.ts";
import { Series } from "@lib/resource/series.ts";
import { fetchResource } from "./marka.ts";
import { listResources } from "./marka/index.ts";
import { GenericResource } from "./marka/schema.ts";
type ResourceType = keyof typeof resources;
@@ -48,8 +44,8 @@ export function parseResourceUrl(_url: string | URL): SearchParams | undefined {
}
const isResource = (
item: Movie | Series | Article | Recipe | boolean,
): item is Movie | Series | Article | Recipe => {
item: GenericResource | boolean | undefined,
): item is GenericResource => {
return !!item;
};
@@ -57,10 +53,10 @@ export async function searchResource(
{ q, tags = [], types, rating }: SearchParams,
): Promise<GenericResource[]> {
const resources = (await Promise.all([
(!types || types.includes("movie")) && fetchResource("movies"),
(!types || types.includes("series")) && fetchResource("series"),
(!types || types.includes("article")) && fetchResource("articles"),
(!types || types.includes("recipe")) && fetchResource("recipes"),
(!types || types.includes("movie")) && listResources("movies"),
(!types || types.includes("series")) && listResources("series"),
(!types || types.includes("article")) && listResources("articles"),
(!types || types.includes("recipe")) && listResources("recipes"),
])).flat().filter(isResource);
const results: Record<string, GenericResource> = {};
@@ -68,27 +64,35 @@ export async function searchResource(
for (const resource of resources) {
if (
!(resource.name in results) &&
tags?.length && resource.tags.length &&
tags.every((t) => resource.tags.includes(t))
tags?.length && resource.content.keywords?.length &&
tags.every((t) => resource.content.keywords?.includes(t))
) {
results[resource.id] = resource;
results[resource.name] = resource;
}
if (
!(resource.id in results) &&
rating && resource?.meta?.rating && resource.meta.rating >= rating
!(resource.name in results) &&
rating && resource?.content?.reviewRating &&
resource.content?.reviewRating?.ratingValue >= rating
) {
results[resource.id] = resource;
results[resource.name] = resource;
}
}
if (q.length && q !== "*") {
const fuzzyResult = fuzzysort.go(q, resources, {
keys: ["content", "name", "description", "meta.author"],
keys: [
"name",
"content.articleBody",
"content.reviewBody",
"content.name",
"content.description",
"content.author.name",
],
threshold: 0.3,
});
for (const result of fuzzyResult) {
results[result.obj.id] = result.obj;
results[result.obj.name] = result.obj;
}
}

View File

@@ -1,5 +1,5 @@
import { transcribe } from "@lib/openai.ts";
import { createDocument } from "@lib/documents.ts";
import { createResource } from "@lib/marka/index.ts";
import { createLogger } from "./log/index.ts";
import { convertOggToMp3 } from "./helpers.ts";
@@ -48,9 +48,8 @@ export async function endTask(chatId: string): Promise<string | null> {
finalNote += "**[Voice message could not be transcribed]**\n\n";
}
} else if (entry.type === "photo") {
const photoUrl = `${
task.noteName.replace(/\.md$/, "")
}/photo-${photoIndex++}.jpg`;
const photoUrl = `${task.noteName.replace(/\.md$/, "")
}/photo-${photoIndex++}.jpg`;
finalNote += `**Photo**:\n ${photoUrl}\n\n`;
photoTasks.push({
@@ -62,13 +61,13 @@ export async function endTask(chatId: string): Promise<string | null> {
try {
for (const entry of photoTasks) {
await createDocument(entry.path, entry.content, "image/jpeg");
await createResource(entry.path, entry.content);
}
} catch (err) {
log.error("Error creating photo document:", err);
}
try {
await createDocument(task.noteName, finalNote, "text/markdown");
await createResource(task.noteName, finalNote);
} catch (error) {
log.error("Error creating document:", error);
return error instanceof Error

View File

@@ -1,4 +1,4 @@
import { resources } from "@lib/resources.ts";
import { GenericResource, GenericResourceSchema } from "./marka/schema.ts";
export interface TMDBMovie {
adult: boolean;
@@ -33,22 +33,6 @@ export interface TMDBSeries {
vote_count: number;
}
export type GenericResource = {
name: string;
id: string;
tags?: string[];
type: keyof typeof resources;
content?: string;
meta?: {
image?: string;
author?: string;
rating?: number;
average?: string;
date?: Date | string;
thumbnail?: string;
};
};
export interface GiteaOauthUser {
sub: string;
name: string;
@@ -61,7 +45,7 @@ export interface GiteaOauthUser {
export type SearchResult = {
id: string;
name: string;
type: keyof typeof resources;
type: GenericResource["content"]["_type"];
date?: string;
rating: number;
tags: string[];

View File

@@ -29,7 +29,7 @@ export default function App({ Component }: PageProps) {
<Component />
</Partial>
</body>
<script src="/thumbnails.js" type="module" async defer />
<script src="/thumbhash.js" type="module" async defer />
</html>
);
}

View File

@@ -3,7 +3,7 @@ import { Handlers, PageProps } from "$fresh/server.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { getLogs, Log } from "@lib/log/index.ts";
import { formatDate } from "@lib/string.ts";
import { renderMarkdown } from "@lib/documents.ts";
import { renderMarkdown } from "@lib/markdown.ts";
const renderLog = (t: unknown) =>
renderMarkdown(`\`\`\`js
@@ -42,7 +42,7 @@ function LogLine(
<span class="bg-gray-600 py-1 px-2 text-xs rounded-xl text-white">
{log.date.getHours().toString().padStart(2, "0")}:{log.date
.getMinutes().toString().padStart(2, "0")}:{log.date.getSeconds()
.toString().padStart(2, "0")} {formatDate(log.date)}
.toString().padStart(2, "0")} {formatDate(log.date)}
</span>
<span class="bg-gray-600 py-1 px-2 text-xs rounded-xl text-white">
{log.scope}

View File

@@ -1,6 +1,6 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers = {
async GET(_, ctx) {

View File

@@ -3,8 +3,6 @@ import { Defuddle } from "defuddle/node";
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
import { createStreamResponse, isValidUrl } from "@lib/helpers.ts";
import * as openai from "@lib/openai.ts";
import { Article } from "@lib/resource/articles.ts";
import { getYoutubeVideoDetails } from "@lib/youtube.ts";
import {
extractYoutubeId,
@@ -12,8 +10,9 @@ import {
toUrlSafeString,
} from "@lib/string.ts";
import { createLogger } from "@lib/log/index.ts";
import { createResource } from "@lib/marka.ts";
import { createResource } from "@lib/marka/index.ts";
import { webScrape } from "@lib/webScraper.ts";
import { ArticleResource } from "@lib/marka/schema.ts";
const log = createLogger("api/article");
@@ -93,7 +92,7 @@ async function processCreateYoutubeVideo(
const id = newId || youtubeId;
const newArticle: Article = {
const newArticle: ArticleResource["content"] = {
_type: "Article",
headline: video.snippet.title,
articleBody: video.snippet.description,

View File

@@ -1,6 +1,6 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers = {
async GET() {

View File

@@ -63,8 +63,8 @@ function parseParams(reqUrl: URL): ImageParams | string {
async function generateETag(content: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest("SHA-256", content);
return `"${Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}"`;
}
@@ -80,13 +80,9 @@ async function GET(req: Request, _ctx: FreshContext): Promise<Response> {
});
}
const imageUrl = params.image.startsWith("resources")
? `https://marka.max-richter.dev/${params.image.replace(/^\//, "")}`
: params.image;
log.debug("Processing image request:", { params });
log.debug("Processing image request:", { imageUrl, params });
const image = await getImageContent(imageUrl, params);
const image = await getImageContent(params.image, params);
// Generate ETag based on image content
const eTag = await generateETag(image.content);

View File

@@ -1,11 +1,11 @@
import { Handlers } from "$fresh/server.ts";
import { Movie } from "@lib/resource/movies.ts";
import { json } from "@lib/helpers.ts";
import * as tmdb from "@lib/tmdb.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { isString, safeFileName } from "@lib/string.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { createResource, fetchResource } from "@lib/marka.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const handler: Handlers = {
async GET(_, ctx) {
@@ -50,7 +50,7 @@ export const handler: Handlers = {
);
}
const movie: Movie = {
const movie: ReviewResource["content"] = {
_type: "Review",
image: `resources/${finalPath}`,
datePublished: releaseDate,

View File

@@ -9,7 +9,7 @@ import {
NotFoundError,
} from "@lib/errors.ts";
import { createRecommendationResource } from "@lib/recommendation.ts";
import { createResource, fetchResource } from "@lib/marka.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
const POST = async (
req: Request,

View File

@@ -1,6 +1,6 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers = {
async GET() {

View File

@@ -1,6 +1,6 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers = {
async GET(_, ctx) {

View File

@@ -3,15 +3,15 @@ import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
import { createStreamResponse, isValidUrl } from "@lib/helpers.ts";
import * as openai from "@lib/openai.ts";
import { createLogger } from "@lib/log/index.ts";
import { Recipe } from "@lib/resource/recipes.ts";
import recipeSchema from "@lib/recipeSchema.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { safeFileName } from "@lib/string.ts";
import { parseJsonLdToRecipeSchema } from "./parseJsonLd.ts";
import z from "zod";
import { createResource } from "@lib/marka.ts";
import { createResource } from "@lib/marka/index.ts";
import { webScrape } from "@lib/webScraper.ts";
import { Defuddle } from "defuddle/node";
import { RecipeResource } from "@lib/marka/schema.ts";
const log = createLogger("api/article");
@@ -51,7 +51,7 @@ async function processCreateRecipeFromUrl(
recipe = await openai.extractRecipe(result.content);
}
const id = safeFileName(recipe?.title || "");
const id = safeFileName(recipe?.name || "");
if (!recipe) {
streamResponse.enqueue("failed to parse recipe");
@@ -59,23 +59,13 @@ async function processCreateRecipeFromUrl(
return;
}
const newRecipe: Recipe = {
const newRecipe: RecipeResource["content"] = {
...recipe,
_type: "Recipe",
name: recipe?.title,
description: recipe?.description,
recipeIngredient: recipe?.ingredients || [],
recipeInstructions: recipe?.instructions || [],
keywords: recipe.tags || [],
image: recipe?.image,
totalTime: recipe?.totalTime
? `${recipe?.totalTime?.toString()} minutes`
: undefined,
url: fetchUrl,
author: {
_type: "Person",
name: recipe?.author,
},
recipeYield: recipe?.servings,
};
if (newRecipe?.image && newRecipe.image.length > 5) {

View File

@@ -1,6 +1,6 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers = {
async GET() {

View File

@@ -1,25 +1,25 @@
import { Handlers } from "$fresh/server.ts";
import { createStreamResponse } from "@lib/helpers.ts";
import { Movie } from "@lib/resource/movies.ts";
import * as tmdb from "@lib/tmdb.ts";
import {
createRecommendationResource,
getRecommendation,
} from "@lib/recommendation.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { fetchResource } from "@lib/marka.ts";
import { listResources } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
async function processUpdateRecommendations(
streamResponse: ReturnType<typeof createStreamResponse>,
) {
const allMovies = await fetchResource("movies");
const allMovies = await listResources<ReviewResource>("movies");
const movies = allMovies?.content.filter((m) => {
const movies = allMovies?.filter((m: ReviewResource) => {
if (!m?.content) return false;
if (!m.content.reviewRating) return false;
if (!m.content.tmdbId) return false;
return true;
}) as Movie[];
}) as ReviewResource[];
streamResponse.enqueue("Fetched all movies");
@@ -27,22 +27,23 @@ async function processUpdateRecommendations(
const total = movies.length;
await Promise.all(movies.map(async (movie) => {
if (!movie.meta.tmdbId) return;
if (!movie.meta.rating) return;
const recommendation = getRecommendation(movie.id, movie.type);
if (!movie.content.tmdbId) return;
if (!movie.content.reviewRating) return;
const recommendation = getRecommendation(movie.name, movie.type);
if (recommendation) {
done++;
return;
}
try {
const movieDetails = await tmdb.getMovie(movie.meta.tmdbId);
const movieDetails = await tmdb.getMovie(movie.content.tmdbId);
await createRecommendationResource(movie, movieDetails.overview);
} catch (err) {
console.log(err);
}
done++;
streamResponse.enqueue(
`${Math.floor((done / total) * 100)}% [${done + 1}/${total}] ${movie.id}`,
`${Math.floor((done / total) * 100)}% [${done + 1
}/${total}] ${movie.name}`,
);
})).catch((err) => {
console.log(err);

View File

@@ -4,8 +4,8 @@ import * as tmdb from "@lib/tmdb.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { isString, safeFileName } from "@lib/string.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { Series } from "@lib/resource/series.ts";
import { createResource, fetchResource } from "@lib/marka.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const handler: Handlers = {
async GET(_, ctx) {
@@ -49,7 +49,7 @@ export const handler: Handlers = {
);
}
const series: Series = {
const series: ReviewResource["content"] = {
_type: "Review",
image: `resources/${finalPath}`,
datePublished: releaseDate,

View File

@@ -9,7 +9,7 @@ import {
BadRequestError,
NotFoundError,
} from "@lib/errors.ts";
import { createResource, fetchResource } from "@lib/marka.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
const isString = (input: string | undefined): input is string => {
return typeof input === "string";

View File

@@ -1,6 +1,6 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers = {
async GET() {

View File

@@ -1,18 +1,18 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { Article } from "@lib/resource/articles.ts";
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 { removeImage, renderMarkdown } from "@lib/documents.ts";
import { removeImage, renderMarkdown } from "@lib/markdown.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import PageHero from "@components/PageHero.tsx";
import { Star } from "@components/Stars.tsx";
import { MetaTags } from "@components/MetaTags.tsx";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
import { ArticleResource } from "@lib/marka/schema.ts";
export const handler: Handlers<{ article: Article; session: unknown }> = {
export const handler: Handlers<{ article: ArticleResource; session: unknown }> = {
async GET(_, ctx) {
const article = await fetchResource(`articles/${ctx.params.name}.md`);
if (!article) {
@@ -30,11 +30,9 @@ export default function Greet(
const { author = "", date = "", articleBody = "" } = article?.content || {};
const content = renderMarkdown(
removeImage(articleBody, article.content.image),
removeImage(articleBody, article.image?.url),
);
console.log({ article });
return (
<MainLayout
url={props.url}
@@ -46,8 +44,8 @@ export default function Greet(
<MetaTags resource={article} />
<PageHero
image={article.content.image}
thumbnail={article.content.thumbnail}
image={article.image?.url}
thumbhash={article.image?.thumbhash}
>
<PageHero.Header>
<PageHero.BackLink href="/articles" />

View File

@@ -1,6 +1,6 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { Article } from "@lib/resource/articles.ts";
import { type ArticleResource } from "@lib/marka/schema.ts";
import { KMenu } from "@islands/KMenu.tsx";
import { Grid } from "@components/Grid.tsx";
import { IconArrowLeft } from "@components/icons.tsx";
@@ -9,13 +9,13 @@ import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { GenericResource } from "@lib/types.ts";
import { ResourceCard } from "@components/Card.tsx";
import { Link } from "@islands/Link.tsx";
import { fetchResource } from "@lib/marka.ts";
import { listResources } from "@lib/marka/index.ts";
export const handler: Handlers<
{ articles: Article[] | null; searchResults?: GenericResource[] }
{ articles: ArticleResource[] | null; searchResults?: GenericResource[] }
> = {
async GET(req, ctx) {
const { content: articles } = await fetchResource("articles");
const articles = await listResources("articles");
const searchParams = parseResourceUrl(req.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["article"] });
@@ -25,7 +25,7 @@ export const handler: Handlers<
export default function Greet(
props: PageProps<
{ articles: Article[] | null; searchResults: GenericResource[] }
{ articles: ArticleResource[] | null; searchResults: GenericResource[] }
>,
) {
const { articles, searchResults } = props.data;

View File

@@ -1,7 +1,7 @@
import { PageProps, RouteContext } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { Movie } from "@lib/resource/movies.ts";
import { removeImage, renderMarkdown } from "@lib/documents.ts";
import { ReviewResource, ReviewSchema } from "@lib/marka/schema.ts";
import { removeImage, renderMarkdown } from "@lib/markdown.ts";
import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { Recommendations } from "@islands/Recommendations.tsx";
@@ -9,20 +9,22 @@ import PageHero from "@components/PageHero.tsx";
import { Star } from "@components/Stars.tsx";
import { MetaTags } from "@components/MetaTags.tsx";
import { parseRating } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
export default async function Greet(
props: PageProps<{ movie: Movie; session: Record<string, string> }>,
props: PageProps<{ movie: ReviewResource; session: Record<string, string> }>,
ctx: RouteContext,
) {
const movie = await fetchResource(`movies/${ctx.params.name}.md`);
const movie = await fetchResource<ReviewResource>(
`movies/${ctx.params.name}.md`,
);
const session = ctx.state.session;
if (!movie) {
return ctx.renderNotFound();
}
const { author = "", date = "" } = movie.content;
const { author = "", datePublished = "" } = movie.content;
const content = renderMarkdown(
removeImage(movie.content.reviewBody || "", movie.content.image),
@@ -34,8 +36,8 @@ export default async function Greet(
<KMenu type="main" context={movie} />
<MetaTags resource={movie} />
<PageHero
image={movie.content.image}
thumbnail={movie.content.thumbnail}
image={movie.image?.url}
thumbhash={movie.image?.thumbhash}
>
<PageHero.Header>
<PageHero.BackLink href="/movies" />
@@ -60,7 +62,7 @@ export default async function Greet(
>
{movie.content.reviewRating && (
<Star
rating={parseRating(movie.content.reviewRating?.ratingValue)}
rating={parseRating(movie.content?.reviewRating?.ratingValue)}
/>
)}
</PageHero.Subline>

View File

@@ -1,5 +1,5 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { Movie } from "@lib/resource/movies.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
import { ResourceCard } from "@components/Card.tsx";
import { Grid } from "@components/Grid.tsx";
import { IconArrowLeft } from "@components/icons.tsx";
@@ -7,15 +7,15 @@ import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { GenericResource } from "@lib/types.ts";
import { PageProps } from "$fresh/server.ts";
import { fetchResource } from "@lib/marka.ts";
import { listResources } from "@lib/marka/index.ts";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
export default async function Greet(
export default async function MovieIndex(
props: PageProps<
{ movies: Movie[] | null; searchResults: GenericResource[] }
{ movies: ReviewResource[] | null; searchResults: GenericResource[] }
>,
) {
const { content: allMovies } = await fetchResource("movies");
const allMovies = await listResources("movies");
const searchParams = parseResourceUrl(props.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["movie"] });
@@ -47,8 +47,8 @@ export default async function Greet(
<h3 class="text-2xl text-white font-light">🍿 Movies</h3>
</header>
<Grid>
{movies?.map((doc) => {
return <ResourceCard res={doc} />;
{movies?.map((doc, i) => {
return <ResourceCard key={i} res={doc} />;
})}
</Grid>
</MainLayout>

View File

@@ -8,10 +8,10 @@ import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import PageHero from "@components/PageHero.tsx";
import { Star } from "@components/Stars.tsx";
import { renderMarkdown } from "@lib/documents.ts";
import { renderMarkdown } from "@lib/markdown.ts";
import { isValidRecipe } from "@lib/recipeSchema.ts";
import { MetaTags } from "@components/MetaTags.tsx";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers<{ recipe: Recipe; session: unknown } | null> = {
async GET(_, ctx) {
@@ -86,8 +86,8 @@ export default function Page(
<MetaTags resource={recipe} />
<PageHero
image={recipe.content?.image}
thumbnail={recipe.content?.thumbnail}
image={recipe.image?.url}
thumbhash={recipe.image?.thumbhash}
>
<PageHero.Header>
<PageHero.BackLink href="/recipes" />

View File

@@ -7,14 +7,14 @@ import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { GenericResource } from "@lib/types.ts";
import { ResourceCard } from "@components/Card.tsx";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource, listResources } from "@lib/marka/index.ts";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
export const handler: Handlers<
{ recipes: Recipe[] | null; searchResults?: GenericResource[] }
> = {
async GET(req, ctx) {
const { content: recipes } = await fetchResource("recipes");
const recipes = await listResources("recipes");
const searchParams = parseResourceUrl(req.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["recipe"] });

View File

@@ -1,17 +1,17 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { HashTags } from "@components/HashTags.tsx";
import { removeImage, renderMarkdown } from "@lib/documents.ts";
import { Series } from "@lib/resource/series.ts";
import { removeImage, renderMarkdown } from "@lib/markdown.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import PageHero from "@components/PageHero.tsx";
import { Star } from "@components/Stars.tsx";
import { MetaTags } from "@components/MetaTags.tsx";
import { parseRating } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka.ts";
import { fetchResource } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const handler: Handlers<{ serie: Series; session: unknown }> = {
export const handler: Handlers<{ serie: ReviewResource; session: unknown }> = {
async GET(_, ctx) {
const serie = await fetchResource(`series/${ctx.params.name}.md`);
@@ -44,8 +44,8 @@ export default function Greet(
<MetaTags resource={serie} />
<PageHero
image={serie.content?.image}
thumbnail={serie.content?.thumbnail}
image={serie.image?.url}
thumbhash={serie.image?.thumbhash}
>
<PageHero.Header>
<PageHero.BackLink href="/series" />

View File

@@ -2,19 +2,18 @@ import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { Grid } from "@components/Grid.tsx";
import { IconArrowLeft } from "@components/icons.tsx";
import { Series } from "@lib/resource/series.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import { ResourceCard } from "@components/Card.tsx";
import { GenericResource } from "@lib/types.ts";
import { fetchResource } from "@lib/marka.ts";
import { listResources } from "@lib/marka/index.ts";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { GenericResource, ReviewResource } from "@lib/marka/schema.ts";
export const handler: Handlers<
{ series: Series[] | null; searchResults?: GenericResource[] }
{ series: ReviewResource[] | null; searchResults?: GenericResource[] }
> = {
async GET(req, ctx) {
const { content: series } = await fetchResource("series");
const series = await listResources("series");
const searchParams = parseResourceUrl(req.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["series"] });
@@ -24,7 +23,7 @@ export const handler: Handlers<
export default function Greet(
props: PageProps<
{ series: Series[] | null; searchResults: GenericResource[] }
{ series: ReviewResource[] | null; searchResults: GenericResource[] }
>,
) {
const { series, searchResults } = props.data;
@@ -50,8 +49,8 @@ export default function Greet(
<h3 class="text-2xl text-white font-light">🎥 Series</h3>
</header>
<Grid>
{series?.map((doc) => {
return <ResourceCard sublink="series" res={doc} />;
{series?.map((doc, i) => {
return <ResourceCard key={i} sublink="series" res={doc} />;
})}
</Grid>
</MainLayout>

View File

@@ -35,7 +35,7 @@ function thumbHashToRGBA(hash) {
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
ac.push(
(((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) &
15) / 7.5 - 1) * scale,
15) / 7.5 - 1) * scale,
);
}
}
@@ -226,7 +226,7 @@ function rgbaToDataURL(w, h, rgba) {
return "data:image/png;base64," + btoa(String.fromCharCode(...bytes));
}
function updateThumbnailImages() {
function updateThumbhashImages() {
document.querySelectorAll("[data-thumb]").forEach((entry) => {
const hash = entry.getAttribute("data-thumb");
@@ -257,7 +257,7 @@ function updateThumbnailImages() {
child.style.opacity = 1;
child.style.filter = "blur(0px)";
setTimeout(() => {
entry.style.background = "";
//entry.style.background = "";
}, 400);
});
}
@@ -265,5 +265,5 @@ function updateThumbnailImages() {
});
}
globalThis.addEventListener("load", updateThumbnailImages);
globalThis.addEventListener("loading-finished", updateThumbnailImages);
globalThis.addEventListener("load", updateThumbhashImages);
globalThis.addEventListener("loading-finished", updateThumbhashImages);