feat: initial refactor to use marka as backend

This commit is contained in:
Max Richter
2025-10-28 20:15:23 +01:00
parent 0beb3b1071
commit f680b5f832
39 changed files with 245 additions and 1012 deletions

View File

@@ -3,6 +3,7 @@ import { IconBrandYoutube } from "@components/icons.tsx";
import { GenericResource } from "@lib/types.ts";
import { SmallRating } from "@components/Rating.tsx";
import { Link } from "@islands/Link.tsx";
import { parseRating } from "@lib/helpers.ts";
export function Card(
{
@@ -96,20 +97,21 @@ export function Card(
export function ResourceCard(
{ res, sublink = "movies" }: { sublink?: string; res: GenericResource },
) {
const { meta: { image } = {} } = res || {};
const img = res?.content?.image || res?.content?.cover;
const imageUrl = image
? `/api/images?image=${image}&width=200&height=200`
const imageUrl = img
? `/api/images?image=${img}&width=200&height=200`
: "/placeholder.svg";
return (
<Card
title={res.name}
title={res.content?.name || res.content?.itemReviewed?.name || res.content?.headline ||
res?.name}
backgroundColor={res.meta?.average}
rating={res.meta?.rating}
thumbnail={res.meta?.thumbnail}
rating={parseRating(res.content?.reviewRating?.ratingValue)}
thumbnail={res.cover}
image={imageUrl}
link={`/${sublink}/${res.id}`}
link={`/${sublink}/${res.name.replace(/\.md$/g, "")}`}
/>
);
}

View File

@@ -2,13 +2,13 @@ import { GenericResource } from "@lib/types.ts";
import { Head } from "$fresh/runtime.ts";
function generateJsonLd(resource: GenericResource): string {
const imageUrl = resource.meta?.image
? `/api/images?image=${resource.meta.image}&width=1200`
const imageUrl = resource.content?.image
? `/api/images?image=${resource.content.image}&width=1200`
: "/images/og-image.jpg";
const baseSchema: Record<string, unknown> = {
"@context": "https://schema.org",
"@type": resource.type.charAt(0).toUpperCase() + resource.type.slice(1), // Converts type to PascalCase
"@type": resource.content?._type, // Converts type to PascalCase
name: resource.name,
description: resource.content || resource.meta?.average || "",
keywords: resource.tags?.join(", ") || "",
@@ -45,14 +45,14 @@ function generateJsonLd(resource: GenericResource): string {
export function MetaTags({ resource }: { resource: GenericResource }) {
const jsonLd = generateJsonLd(resource);
const imageUrl = resource.meta?.image
? `/api/images?image=${resource.meta.image}&width=1200`
const imageUrl = resource.content?.image
? `/api/images?image=${resource.content.image}&width=1200`
: "/images/og-image.jpg";
return (
<>
<Head>
<meta property="og:title" content={resource.name} />
<meta property="og:type" content={resource.type} />
<meta property="og:title" content={resource.content?.name} />
<meta property="og:type" content={resource.content?._type} />
<meta
property="og:image"
content={imageUrl}

View File

@@ -2,6 +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";
function formatAmount(num: number) {
if (num === 0) return "";
@@ -62,39 +63,19 @@ export const IngredientsList: FunctionalComponent<
> = (
{ ingredients, amount, portion },
) => {
return (
<table class="w-full border-collapse table-auto">
<tbody>
{ingredients.map((item, index) => {
if ("items" in item) {
// Render IngredientGroup
const { name, items: groupIngredients } = item as IngredientGroup;
return (
<table class="w-full border-collapse table-auto">
<tbody>
{ingredients.filter((s) => !!s?.length).map((item) => {
return (
<>
<tr key={index}>
<td colSpan={3} class="pr-4 py-2 font-italic">{name}</td>
</tr>
{groupIngredients.map((item, index) => {
// Render Ingredient
return (
<Ingredient
key={index}
ingredient={item}
amount={amount}
portion={portion}
/>
);
})}
</>
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(item) }}>
</div>
);
} else {
return (
<Ingredient ingredient={item} amount={amount} portion={portion} />
);
}
})}
</tbody>
</table>
);
};
// return (
// <Ingredient ingredient={item} amount={amount} portion={portion} />
// );
})}
</tbody>
</table>
);
};

View File

@@ -1,149 +0,0 @@
import {
createDocument,
getDocument,
getDocuments,
transformDocument,
} from "@lib/documents.ts";
import { Root } from "https://esm.sh/remark-frontmatter@4.0.1";
import { GenericResource } from "@lib/types.ts";
import { parseRating } from "@lib/helpers.ts";
import { isLocalImage } from "@lib/string.ts";
import { SILVERBULLET_SERVER } from "@lib/env.ts";
import { imageTable } from "@lib/db/schema.ts";
import { db } from "@lib/db/sqlite.ts";
import { eq } from "drizzle-orm/sql";
import { createCache } from "@lib/cache.ts";
export async function addThumbnailToResource<T extends GenericResource>(
res: T,
): Promise<T> {
if (!res?.meta?.image) return res;
const imageUrl = isLocalImage(res.meta.image)
? `${SILVERBULLET_SERVER}/${res.meta.image}`
: res.meta.image;
const image = await db.select().from(imageTable)
.where(eq(imageTable.url, imageUrl))
.limit(1)
.then((images) => images[0]);
if (image) {
return {
...res,
meta: {
...res.meta,
average: image.average,
thumbnail: image.blurhash,
},
};
}
return res;
}
type SortType = "rating" | "date" | "name" | "author";
function sortFunction<T extends GenericResource>(sortType: SortType) {
return (a: T, b: T) => {
switch (sortType) {
case "rating":
return parseRating(a.meta?.rating || 0) >
parseRating(b.meta?.rating || 0)
? -1
: 1;
case "date":
return (a.meta?.date || 0) > (b.meta?.date || 0) ? -1 : 1;
case "name":
return a.name.localeCompare(b.name);
case "author":
return a.meta?.author?.localeCompare(b.meta?.author || "") || 0;
default:
return 0;
}
};
}
export function createCrud<T extends GenericResource>(
{ prefix, parse, render, hasThumbnails = false }: {
prefix: string;
hasThumbnails?: boolean;
render?: (doc: T) => string;
parse: (doc: string, id: string) => T;
},
) {
const cache = createCache<T>(`crud/${prefix}`, { expires: 60 * 1000 });
function pathFromId(id: string) {
return `${prefix}${id.replaceAll(":", "")}.md`;
}
async function read(id: string) {
const path = pathFromId(id);
const content = await getDocument(path);
if (!content) {
return;
}
let parsed = parse(content, id);
if (hasThumbnails) {
parsed = await addThumbnailToResource(parsed);
}
const doc = { ...parsed, content };
return doc;
}
function create(id: string, content: string | ArrayBuffer | T) {
const path = pathFromId(id);
cache.set("all", undefined);
if (
typeof content === "string" || content instanceof ArrayBuffer
) {
return createDocument(path, content);
}
if (render) {
const rendered = render(content);
return createDocument(path, rendered);
}
throw new Error("No renderer defined for " + prefix + " CRUD");
}
async function update(id: string, updater: (r: Root) => Root) {
const path = pathFromId(id);
const content = await getDocument(path);
if (!content) {
return;
}
const newDoc = transformDocument(content, updater);
await createDocument(path, newDoc);
}
async function readAll({ sort = "rating" }: { sort?: SortType } = {}) {
if (cache.has("all")) {
return cache.get("all") as unknown as T[];
}
const allDocuments = await getDocuments();
const parsed = (await Promise.all(
allDocuments.filter((d) => {
return d.name.startsWith(prefix) &&
d.contentType === "text/markdown" &&
!d.name.endsWith("index.md");
}).map((doc) => {
const id = doc.name.replace(prefix, "").replace(/\.md$/, "");
return read(id);
}),
)).sort(sortFunction<T>(sort)).filter((v) => !!v);
return parsed;
}
return {
read,
readAll,
create,
update,
};
}

View File

@@ -1,5 +1,5 @@
import { drizzle } from "drizzle-orm/libsql/node";
import { DATA_DIR } from "@lib/env.ts";
import { drizzle } from "drizzle-orm/libsql";
import path from "node:path";
const DB_FILE = "file:" + path.resolve(DATA_DIR, "db.sqlite");

View File

@@ -1,19 +1,7 @@
import { unified } from "https://esm.sh/unified@10.1.2";
import { render } from "gfm";
import "https://esm.sh/prismjs@1.29.0/components/prism-typescript?no-check";
import "https://esm.sh/prismjs@1.29.0/components/prism-bash?no-check";
import "https://esm.sh/prismjs@1.29.0/components/prism-rust?no-check";
import remarkParse from "https://esm.sh/remark-parse@10.0.2";
import remarkStringify from "https://esm.sh/remark-stringify@10.0.3";
import remarkFrontmatter, {
Root,
} from "https://esm.sh/remark-frontmatter@4.0.1";
import { SILVERBULLET_SERVER } from "@lib/env.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
import { createLogger } from "@lib/log/index.ts";
import { db } from "@lib/db/sqlite.ts";
import { documentTable } from "@lib/db/schema.ts";
import { eq } from "drizzle-orm/sql";
export type Document = {
name: string;
@@ -24,114 +12,6 @@ export type Document = {
perm: string;
};
const log = createLogger("documents");
export async function getDocuments(): Promise<Document[]> {
let documents = await db.select().from(documentTable).all();
if (documents.length) return documents;
const headers = new Headers();
headers.append("Accept", "application/json");
headers.append("X-Sync-Mode", "true");
log.debug("fetching all documents");
const response = await fetch(`${SILVERBULLET_SERVER}/index.json`, {
headers: headers,
});
documents = await response.json();
await db.delete(documentTable);
await db.insert(documentTable).values(documents);
return documents;
}
export function createDocument(
name: string,
content: string | ArrayBuffer,
mediaType?: string,
) {
const headers = new Headers();
if (mediaType) {
headers.append("Content-Type", mediaType);
}
log.info("creating document", { name });
if (typeof content === "string") {
updateDocument(name, content).catch(log.error);
}
return fetch(SILVERBULLET_SERVER + "/" + name, {
body: content,
method: "PUT",
headers,
});
}
async function fetchDocument(name: string) {
log.debug("fetching document", { name });
const headers = new Headers();
headers.append("X-Sync-Mode", "true");
const response = await fetch(SILVERBULLET_SERVER + "/" + name, { headers });
if (response.status === 404) {
return;
}
return response.text();
}
export async function getDocument(name: string): Promise<string | undefined> {
const documents = await db.select().from(documentTable).where(
eq(documentTable.name, name),
).limit(1);
// This updates the document in the background
fetchDocument(name).then((content) => {
if (content) {
updateDocument(name, content);
} else {
db.delete(documentTable).where(eq(documentTable.name, name));
}
}).catch(
log.error,
);
if (documents[0]?.content) return documents[0].content;
const text = await fetchDocument(name);
if (!text) {
db.delete(documentTable).where(eq(documentTable.name, name));
return;
}
await updateDocument(name, text);
return text;
}
export function updateDocument(name: string, content: string) {
return db.update(documentTable).set({
content,
}).where(eq(documentTable.name, name)).run();
}
export function transformDocument(input: string, cb: (r: Root) => Root) {
const out = unified()
.use(remarkParse)
.use(remarkFrontmatter, ["yaml"])
.use(() => (tree) => {
return cb(tree);
})
.use(remarkStringify)
.processSync(input);
return fixRenderedMarkdown(String(out));
}
export function parseDocument(doc: string) {
return unified()
.use(remarkParse)
.use(remarkFrontmatter, ["yaml", "toml"])
.parse(doc);
}
function removeFrontmatter(doc: string) {
if (doc.trim().startsWith("---")) {
return doc.trim().split("---").filter((s) => s.length).slice(1).join("---");
@@ -160,42 +40,15 @@ export function removeImage(doc: string, imageUrl?: string) {
export function renderMarkdown(doc: string) {
return render(removeFrontmatter(doc), {
baseUrl: SILVERBULLET_SERVER,
baseUrl: "https://max-richter.dev",
allowMath: true,
});
}
export type ParsedDocument = ReturnType<typeof parseDocument>;
export type DocumentChild = ParsedDocument["children"][number];
export function findRangeOfChildren(children: DocumentChild[]) {
const firstChild = children[0];
const lastChild = children.length > 1
? children[children.length - 1]
: firstChild;
const start = firstChild.position?.start.offset;
const end = lastChild.position?.end.offset;
if (typeof start !== "number" || typeof end !== "number") return;
return [start, end];
}
export function getTextOfRange(children: DocumentChild[], text: string) {
if (!children || children.length === 0) {
return;
}
const range = findRangeOfChildren(children);
if (!range) return;
return text.substring(range[0], range[1]);
}
export function getTextOfChild(child: DocumentChild): string | undefined {
if ("value" in child) return child.value;
if ("children" in child) {
return getTextOfChild(child.children[0]);
}
return;
export function createDocument(
path: string,
entry: string,
mimetype = "image/jpeg",
) {
console.log("creating", { path, entry, mimetype });
}

View File

@@ -4,7 +4,6 @@ export const PROXY_SERVER = Deno.env.get("PROXY_SERVER");
export const PROXY_USERNAME = Deno.env.get("PROXY_USERNAME");
export const PROXY_PASSWORD = Deno.env.get("PROXY_PASSWORD");
export const SILVERBULLET_SERVER = Deno.env.get("SILVERBULLET_SERVER");
export const TMDB_API_KEY = Deno.env.get("TMDB_API_KEY");
export const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
export const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY");
@@ -15,8 +14,6 @@ export const GITEA_CLIENT_ID = Deno.env.get("GITEA_CLIENT_ID")!;
export const GITEA_CLIENT_SECRET = Deno.env.get("GITEA_CLIENT_SECRET");
export const GITEA_REDIRECT_URL = Deno.env.get("GITEA_REDIRECT_URL");
export const DATABASE_URL = Deno.env.get("DATABASE_URL") || "dev.db";
const duration = Deno.env.get("SESSION_DURATION");
export const SESSION_DURATION = duration ? +duration : (60 * 60 * 24);

View File

@@ -103,7 +103,7 @@ export function debounce<T extends (...args: Parameters<T>) => void>(
export function parseRating(rating: string | number) {
if (typeof rating === "string") {
return [...rating.matchAll(/⭐/)].length;
return [...rating.matchAll(/⭐/g)].length;
}
return rating;
}

View File

@@ -17,25 +17,31 @@ export const IngredientGroupSchema = z.object({
export type IngredientGroup = z.infer<typeof IngredientGroupSchema>;
const recipeSchema = z.object({
title: z.string().describe(
"Title of the Recipe, without the name of the website or author",
),
image: z.string().describe("URL of the main image of the recipe"),
author: z.string().describe("author of the Recipe (optional)"),
description: z.string().describe("Optional, short description of the recipe"),
ingredients: z.array(z.union([IngredientSchema, IngredientGroupSchema]))
.describe("List of ingredients"),
instructions: z.array(z.string()).describe("List of instructions"),
servings: z.number().describe("Amount of Portions"),
prepTime: z.number().describe("Preparation time in minutes"),
cookTime: z.number().describe("Cooking time in minutes"),
totalTime: z.number().describe("Total time in minutes"),
tags: z.array(z.string()).describe(
"List of tags (e.g., ['vegan', 'dessert'])",
),
notes: z.array(z.string()).describe("Optional notes about the recipe"),
name: z.string(),
content: z.object({
_type: z.literal("Recipe"),
name: z.string().describe(
"Title of the Recipe, without the name of the website or author",
),
description: z.string().describe(
"Optional, short description of the recipe",
),
image: z.string().describe("URL of the main image of the recipe"),
author: z.object({
_type: z.literal("Person"),
name: z.string().describe("author of the Recipe (optional)"),
}),
recipeEngredient: z.array(z.string())
.describe("List of ingredients"),
recipeInstructions: z.array(z.string()).describe("List of instructions"),
recipeYield: z.number().describe("Amount of Portions"),
prepTime: z.number().describe("Preparation time in minutes"),
cookTime: z.number().describe("Cooking time in minutes"),
}),
});
export type Recipe = z.infer<typeof recipeSchema>;
const noRecipeSchema = z.object({
errorMessages: z.array(z.string()).describe(
"List of error messages, if no recipe was found",
@@ -46,12 +52,13 @@ export const recipeResponseSchema = z.union([recipeSchema, noRecipeSchema]);
export function isValidRecipe(
recipe:
| { ingredients?: unknown[]; instructions?: string[]; name?: string }
| Recipe
| null
| undefined,
) {
return recipe?.ingredients?.length && recipe.ingredients.length > 1 &&
recipe?.instructions?.length &&
return recipe?.content?.recipeIngredient?.length &&
recipe?.content?.recipeIngredient.length > 1 &&
recipe?.content?.recipeInstructions?.length &&
recipe.name?.length;
}

View File

@@ -1,9 +1,3 @@
import { parseDocument } from "@lib/documents.ts";
import { parse, stringify } from "@std/yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
export type Article = {
id: string;
type: "article";
@@ -21,88 +15,3 @@ export type Article = {
rating?: number;
};
};
function renderArticle(article: Article) {
const meta = article.meta;
if ("date" in meta) {
meta.date = formatDate(meta.date);
}
return fixRenderedMarkdown(`${meta
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${article.name}
${article.tags.map((t) => `#${t}`).join(" ")}
${article.content}
`);
}
function parseArticle(original: string, id: string): Article {
const doc = parseDocument(original);
let meta = {} as Article["meta"];
let name = "";
const range = [Infinity, -Infinity];
for (const child of doc.children) {
if (child.type === "yaml") {
try {
meta = parse(child.value) as Article["meta"];
} catch (err) {
console.log("Error parsing YAML", err);
console.log("YAML:", child.value);
}
if (meta["rating"] && typeof meta["rating"] === "string") {
meta.rating = [...meta.rating?.matchAll("⭐")].length;
}
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (name) {
const start = child.position?.start.offset || Infinity;
const end = child.position?.end.offset || -Infinity;
if (start < range[0]) range[0] = start;
if (end > range[1]) range[1] = end;
}
}
let content = original.slice(range[0], range[1]);
const tags = extractHashTags(content);
for (const tag of tags) {
content = content.replace("#" + tag, "");
}
return {
type: "article",
id,
name,
tags,
content,
meta,
};
}
const crud = createCrud<Article>({
prefix: "Media/articles/",
parse: parseArticle,
render: renderArticle,
hasThumbnails: true,
});
export const getAllArticles = crud.readAll;
export const getArticle = crud.read;
export const createArticle = crud.create;

View File

@@ -1,9 +1,3 @@
import { parseDocument } from "@lib/documents.ts";
import { parse, stringify } from "yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
export type Movie = {
id: string;
name: string;
@@ -21,103 +15,3 @@ export type Movie = {
rating: number;
};
};
export function renderMovie(movie: Movie) {
const meta = movie.meta;
if ("date" in meta && typeof meta.date !== "string") {
meta.date = formatDate(meta.date) as unknown as Date;
}
delete meta.thumbnail;
delete meta.average;
const movieImage = `![](${movie.meta.image})`;
return fixRenderedMarkdown(`${
meta
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${movie.name}
${
// So we do not add a new image to the description everytime we render
(movie.meta.image && !movie.description.includes(movieImage))
? movieImage
: ""}
${movie.tags.map((t) => `#${t}`).join(" ")}
${movie.description}
`);
}
export function parseMovie(original: string, id: string): Movie {
const doc = parseDocument(original);
let meta = {} as Movie["meta"];
let name = "";
const range = [Infinity, -Infinity];
for (const child of doc.children) {
if (child.type === "yaml") {
try {
meta = (parse(child.value) || {}) as Movie["meta"];
} catch (_) {
// ignore here
}
if (meta["rating"] && typeof meta["rating"] === "string") {
meta.rating = [...meta.rating?.matchAll("⭐")].length;
}
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (name) {
const start = child.position?.start.offset || Infinity;
const end = child.position?.end.offset || -Infinity;
if (start < range[0]) range[0] = start;
if (end > range[1]) range[1] = end;
}
}
let description = original.slice(range[0], range[1]);
const tags = extractHashTags(description);
for (const tag of tags) {
description = description.replace("#" + tag, "");
}
return {
type: "movie",
id,
name,
tags,
description,
meta,
};
}
const crud = createCrud<Movie>({
prefix: "Media/movies/",
parse: parseMovie,
render: renderMovie,
hasThumbnails: true,
});
export const getMovie = async (id: string) => {
const movie = await crud.read(id);
return movie;
};
export const getAllMovies = crud.readAll;
export const createMovie = crud.create;

View File

@@ -1,15 +1,3 @@
import {
type DocumentChild,
getTextOfRange,
parseDocument,
} from "@lib/documents.ts";
import { parse, stringify } from "yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags } from "@lib/string.ts";
import { Ingredient, IngredientGroup } from "@lib/recipeSchema.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
import { parseIngredients } from "@lib/parseIngredient.ts";
export type Recipe = {
type: "recipe";
id: string;
@@ -31,179 +19,3 @@ export type Recipe = {
thumbnail?: string;
};
};
function extractSteps(
content: string,
seperator: RegExp = /\n(?=\d+\.)/g,
): string[] {
const steps = content.split(seperator).map((step) => {
const match = step.match(/^(\d+)\.\s*(.*)/);
if (match) return match[2];
return step;
}).filter((step) => !!step);
return steps as string[];
}
export function parseRecipe(original: string, id: string): Recipe {
const doc = parseDocument(original);
let name = "";
let meta: Recipe["meta"] = {};
const groups: DocumentChild[][] = [];
let group: DocumentChild[] = [];
for (const child of doc.children) {
if (child.type === "yaml") {
try {
meta = parse(child.value) as Recipe["meta"];
} catch (err) {
console.log("Error parsing YAML", err);
}
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (child.type === "thematicBreak") {
groups.push(group);
group = [];
continue;
}
group.push(child);
}
if (group.length) {
groups.push(group);
}
let description = getTextOfRange(groups[0], original);
let ingredientsText = getTextOfRange(groups[1], original);
if (ingredientsText) {
ingredientsText = ingredientsText.replace(/#+\s?Ingredients?/, "");
} else {
ingredientsText = "";
}
const ingredients = parseIngredients(ingredientsText);
const instructionText = getTextOfRange(groups[2], original);
let instructions = extractSteps(instructionText || "");
if (instructions.length <= 1) {
const d = extractSteps(instructionText || "", /\n/g);
if (d.length > instructions.length) {
instructions = d;
}
}
const tags = extractHashTags(description || "");
if (description) {
for (const tag of tags) {
description = description.replace("#" + tag, "");
}
}
return {
type: "recipe",
id,
meta,
name,
tags,
markdown: original,
notes: getTextOfRange(groups[3], original)?.split("\n"),
description,
ingredients,
instructions,
};
}
function filterUndefinedFromObject<T extends { [key: string]: unknown }>(
obj: T,
) {
return Object.fromEntries(
Object.entries(obj).filter(([_, v]) => v !== undefined),
);
}
export function renderRecipe(recipe: Recipe) {
const meta = filterUndefinedFromObject(recipe.meta || {});
// Clean up meta properties
delete meta.thumbnail;
delete meta.average;
const recipeImage = meta.image ? `![](${meta.image})` : "";
// Format ingredient groups and standalone ingredients
const ingredients = recipe.ingredients
.map((item) => {
if ("items" in item) {
return `\n*${item.name}*\n${
item.items
.map((ing) => {
if (ing.quantity && ing.unit) {
return `- **${ing.quantity.trim() || ""}${
ing.unit.trim() || ""
}** ${ing.name}`;
}
return `- ${ing.name}`;
})
.join("\n")
}`;
}
if (item.quantity && item.unit) {
return `- **${item.quantity?.trim() || ""}${
item.unit?.trim() || ""
}** ${item.name}`;
}
if (item.quantity) {
return `- **${item.quantity}** ${item.name}`;
}
return `- ${item.name}`;
})
.join("\n");
// Format instructions as a numbered list
const instructions = recipe.instructions
? recipe.instructions.map((step, i) => `${i + 1}. ${step}`).join("\n")
: "";
// Render the final markdown
return fixRenderedMarkdown(`${
Object.keys(meta).length
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${recipe.name}
${recipe.meta?.image ? recipeImage : ""}
${recipe.tags.map((t) => `#${t.replaceAll(" ", "-")}`).join(" ")}
${recipe.description || ""}
---
${ingredients ? `## Ingredients\n\n${ingredients}\n\n---\n` : ""}
${instructions ? `${instructions}\n\n---` : ""}
${recipe.notes?.length ? `\n${recipe.notes.join("\n")}` : ""}
`);
}
const crud = createCrud<Recipe>({
prefix: `Recipes/`,
parse: parseRecipe,
render: renderRecipe,
hasThumbnails: true,
});
export const getAllRecipes = crud.readAll;
export const getRecipe = crud.read;
export const updateRecipe = crud.update;
export const createRecipe = crud.create;

View File

@@ -1,9 +1,3 @@
import { parseDocument } from "@lib/documents.ts";
import { parse, stringify } from "yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
export type Series = {
id: string;
name: string;
@@ -21,99 +15,3 @@ export type Series = {
done?: boolean;
};
};
function renderSeries(series: Series) {
const meta = series.meta;
if ("date" in meta) {
meta.date = formatDate(meta.date);
}
delete meta.thumbnail;
delete meta.average;
const movieImage = `![](${series.meta.image})`;
return fixRenderedMarkdown(`${
meta
? `---
${stringify(meta)}
---`
: `---
---`
}
# ${series.name}
${
// So we do not add a new image to the description everytime we render
(series.meta.image && !series.description.includes(movieImage))
? movieImage
: ""}
${series.tags.map((t) => `#${t}`).join(" ")}
${series.description}
`);
}
export function parseSeries(original: string, id: string): Series {
const doc = parseDocument(original);
let meta = {} as Series["meta"];
let name = "";
const range = [Infinity, -Infinity];
for (const child of doc.children) {
if (child.type === "yaml") {
try {
meta = (parse(child.value) || {}) as Series["meta"];
} catch (_) {
// ignore here
}
if (meta["rating"] && typeof meta["rating"] === "string") {
meta.rating = [...meta.rating?.matchAll("⭐")].length;
}
continue;
}
if (
child.type === "heading" && child.depth === 1 && !name &&
child.children.length === 1 && child.children[0].type === "text"
) {
name = child.children[0].value;
continue;
}
if (name) {
const start = child.position?.start.offset || Infinity;
const end = child.position?.end.offset || -Infinity;
if (start < range[0]) range[0] = start;
if (end > range[1]) range[1] = end;
}
}
let description = original.slice(range[0], range[1]);
const tags = extractHashTags(description);
for (const tag of tags) {
description = description.replace("#" + tag, "");
}
return {
type: "series",
id,
name,
tags,
description,
meta,
};
}
const crud = createCrud<Series>({
prefix: "Media/series/",
parse: parseSeries,
render: renderSeries,
hasThumbnails: true,
});
export const getSeries = crud.read;
export const getAllSeries = crud.readAll;
export const createSeries = crud.create;

View File

@@ -30,3 +30,14 @@ export const resources = {
prefix: "Media/series/",
},
} as const;
export async function fetchResource(resource: string) {
try {
const response = await fetch(
`https://marka.max-richter.dev/resources/${resource}`,
);
return response.json();
} catch (_e) {
return [];
}
}

View File

@@ -2,10 +2,11 @@ import { resources } from "@lib/resources.ts";
import fuzzysort from "npm:fuzzysort";
import { GenericResource } from "@lib/types.ts";
import { extractHashTags } from "@lib/string.ts";
import { getAllMovies, Movie } from "@lib/resource/movies.ts";
import { Article, getAllArticles } from "@lib/resource/articles.ts";
import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts";
import { getAllSeries, Series } from "@lib/resource/series.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 "./resources.ts";
type ResourceType = keyof typeof resources;
@@ -56,10 +57,10 @@ export async function searchResource(
{ q, tags = [], types, rating }: SearchParams,
): Promise<GenericResource[]> {
const resources = (await Promise.all([
(!types || types.includes("movie")) && getAllMovies(),
(!types || types.includes("series")) && getAllSeries(),
(!types || types.includes("article")) && getAllArticles(),
(!types || types.includes("recipe")) && getAllRecipes(),
(!types || types.includes("movie")) && fetchResource("movies"),
(!types || types.includes("series")) && fetchResource("series"),
(!types || types.includes("article")) && fetchResource("articles"),
(!types || types.includes("recipe")) && fetchResource("recipes"),
])).flat().filter(isResource);
const results: Record<string, GenericResource> = {};

View File

@@ -7,6 +7,6 @@
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";
import "@lib/telegram.ts";
// import "@lib/telegram.ts";
await start(manifest, config);

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { getArticle } from "@lib/resource/articles.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/resources.ts";
export const handler: Handlers = {
async GET(_, ctx) {
const article = await getArticle(ctx.params.name);
const article = await fetchResource(`articles/${ctx.params.name}`);
return json(article);
},
};

View File

@@ -6,7 +6,7 @@ import { createStreamResponse, isValidUrl } from "@lib/helpers.ts";
import * as openai from "@lib/openai.ts";
import tds from "https://cdn.skypack.dev/turndown@7.2.0";
import { Article, createArticle } from "@lib/resource/articles.ts";
import { Article } from "@lib/resource/articles.ts";
import { getYoutubeVideoDetails } from "@lib/youtube.ts";
import {
extractYoutubeId,
@@ -170,7 +170,7 @@ async function processCreateArticle(
streamResponse.enqueue("writing to disk");
await createArticle(newArticle.id, newArticle);
// await createArticle(newArticle.id, newArticle);
streamResponse.enqueue("id: " + newArticle.id);
}
@@ -210,7 +210,7 @@ async function processCreateYoutubeVideo(
streamResponse.enqueue("creating article");
await createArticle(newArticle.id, newArticle);
// await createArticle(newArticle.id, newArticle);
streamResponse.enqueue("finished");

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { getAllArticles } from "@lib/resource/articles.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/resources.ts";
export const handler: Handlers = {
async GET() {
const movies = await getAllArticles();
return json(movies);
const articles = await fetchResource("articles");
return json(articles?.content);
},
};

View File

@@ -1,8 +1,6 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { getImageContent } from "@lib/image.ts";
import { SILVERBULLET_SERVER } from "@lib/env.ts";
import { createLogger } from "@lib/log/index.ts";
import { isLocalImage } from "@lib/string.ts";
const log = createLogger("api/image");
@@ -64,11 +62,10 @@ function parseParams(reqUrl: URL): ImageParams | string {
// Helper function to generate ETag
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("")
}"`;
return `"${Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}"`;
}
async function GET(req: Request, _ctx: FreshContext): Promise<Response> {
@@ -83,8 +80,8 @@ async function GET(req: Request, _ctx: FreshContext): Promise<Response> {
});
}
const imageUrl = isLocalImage(params.image)
? `${SILVERBULLET_SERVER}/${params.image.replace(/^\//, "")}`
const imageUrl = params.image.startsWith("resources")
? `https://marka.max-richter.dev/${params.image.replace(/^\//, "")}`
: params.image;
log.debug("Processing image request:", { imageUrl, params });

View File

@@ -1,10 +1,8 @@
import { Handlers } from "$fresh/server.ts";
import { getDocuments } from "@lib/documents.ts";
import { json } from "@lib/helpers.ts";
export const handler: Handlers = {
async GET() {
const documents = await getDocuments();
return json(documents);
return json([]);
},
};

View File

@@ -1,16 +1,16 @@
import { Handlers } from "$fresh/server.ts";
import { createMovie, getMovie, Movie } from "@lib/resource/movies.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 { createDocument } from "@lib/documents.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { fetchResource } from "@lib/resources.ts";
export const handler: Handlers = {
async GET(_, ctx) {
const movie = await getMovie(ctx.params.name);
return json(movie);
const movie = await fetchResource(`movies/${ctx.params.name}`);
return json(movie?.content);
},
async POST(_, ctx) {
const session = ctx.state.session;

View File

@@ -1,7 +1,5 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { createDocument } from "@lib/documents.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { createMovie, getMovie } from "@lib/resource/movies.ts";
import * as tmdb from "@lib/tmdb.ts";
import { isString, safeFileName } from "@lib/string.ts";
import { json } from "@lib/helpers.ts";
@@ -11,6 +9,7 @@ import {
NotFoundError,
} from "@lib/errors.ts";
import { createRecommendationResource } from "@lib/recommendation.ts";
import { fetchResource } from "@lib/resources.ts";
const POST = async (
req: Request,
@@ -21,7 +20,7 @@ const POST = async (
throw new AccessDeniedError();
}
const movie = await getMovie(ctx.params.name);
const movie = await fetchResource(`movies/${ctx.params.name}`);
if (!movie) {
throw new NotFoundError();
}
@@ -72,12 +71,12 @@ const POST = async (
const poster = await tmdb.getMoviePoster(posterPath);
const extension = fileExtension(posterPath);
finalPath = `Media/movies/images/${safeFileName(name)}_cover.${extension}`;
await createDocument(finalPath, poster);
// await createDocument(finalPath, poster);
movie.meta = movie.meta || {};
movie.meta.image = finalPath;
}
await createMovie(movie.id, movie);
// await createMovie(movie.id, movie);
createRecommendationResource(movie, movieDetails.overview);

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { getAllMovies } from "@lib/resource/movies.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/resources.ts";
export const handler: Handlers = {
async GET() {
const movies = await getAllMovies();
const movies = await fetchResource("movies");
return json(movies);
},
};

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { getRecipe } from "@lib/resource/recipes.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/resources.ts";
export const handler: Handlers = {
async GET(_, ctx) {
const recipe = await getRecipe(ctx.params.name);
const recipe = await fetchResource(`recipes/${ctx.params.name}`);
return json(recipe);
},
};

View File

@@ -6,8 +6,8 @@ import { createStreamResponse, isValidUrl } from "@lib/helpers.ts";
import * as openai from "@lib/openai.ts";
import tds from "https://cdn.skypack.dev/turndown@7.2.0";
import { createLogger } from "@lib/log/index.ts";
import { createRecipe, Recipe } from "@lib/resource/recipes.ts";
import recipeSchema, { isValidRecipe } from "@lib/recipeSchema.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 { createDocument } from "@lib/documents.ts";
@@ -205,10 +205,10 @@ async function processCreateRecipeFromUrl(
streamResponse.enqueue("downloading image");
try {
streamResponse.enqueue("downloading image");
const res = await fetch(src);
// const res = await fetch(src);
streamResponse.enqueue("saving image");
const buffer = await res.arrayBuffer();
await createDocument(finalPath, buffer);
// const buffer = await res.arrayBuffer();
// await createDocument(finalPath, buffer);
newRecipe.meta.image = finalPath;
} catch (err) {
console.log("Failed to save image", err);
@@ -218,7 +218,7 @@ async function processCreateRecipeFromUrl(
streamResponse.enqueue("finished processing, creating file");
await createRecipe(newRecipe.id, newRecipe);
// await createRecipe(newRecipe.id, newRecipe);
streamResponse.enqueue("id: " + newRecipe.id);
}

View File

@@ -1,10 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { getAllRecipes } from "@lib/resource/recipes.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/resources.ts";
export const handler: Handlers = {
async GET() {
const recipes = await getAllRecipes();
const recipes = await fetchResource("recipes");
return json(recipes);
},
};

View File

@@ -1,22 +1,23 @@
import { Handlers } from "$fresh/server.ts";
import { createStreamResponse } from "@lib/helpers.ts";
import { getAllMovies, Movie } from "@lib/resource/movies.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/resources.ts";
async function processUpdateRecommendations(
streamResponse: ReturnType<typeof createStreamResponse>,
) {
const allMovies = await getAllMovies();
const allMovies = await fetchResource("movies");
const movies = allMovies.filter((m) => {
if (!m?.meta) return false;
if (!m.meta.rating) return false;
if (!m.meta.tmdbId) return false;
const movies = allMovies?.content.filter((m) => {
if (!m?.content) return false;
if (!m.content.reviewRating) return false;
if (!m.content.tmdbId) return false;
return true;
}) as Movie[];

View File

@@ -5,11 +5,12 @@ import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts"
import { isString, safeFileName } from "@lib/string.ts";
import { createDocument } from "@lib/documents.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { createSeries, getSeries, Series } from "@lib/resource/series.ts";
import { Series } from "@lib/resource/series.ts";
import { fetchResource } from "@lib/resources.ts";
export const handler: Handlers = {
async GET(_, ctx) {
const series = await getSeries(ctx.params.name);
const series = await fetchResource(`series/${ctx.params.name}`);
return json(series);
},
async POST(_, ctx) {

View File

@@ -9,7 +9,6 @@ import {
BadRequestError,
NotFoundError,
} from "@lib/errors.ts";
import { createSeries, getSeries } from "@lib/resource/series.ts";
const isString = (input: string | undefined): input is string => {
return typeof input === "string";
@@ -64,15 +63,15 @@ const POST = async (
let finalPath = "";
if (posterPath && !series.meta?.image) {
const poster = await tmdb.getMoviePoster(posterPath);
// const poster = await tmdb.getMoviePoster(posterPath);
const extension = fileExtension(posterPath);
finalPath = `Media/series/images/${safeFileName(name)}_cover.${extension}`;
await createDocument(finalPath, poster);
// await createDocument(finalPath, poster);
series.meta = series.meta || {};
series.meta.image = finalPath;
}
await createSeries(series.id, series);
// await createSeries(series.id, series);
return json(series);
};

View File

@@ -1,6 +1,6 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { Article, getArticle } from "@lib/resource/articles.ts";
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";
@@ -10,10 +10,11 @@ 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/resources.ts";
export const handler: Handlers<{ article: Article; session: unknown }> = {
async GET(_, ctx) {
const article = await getArticle(ctx.params.name);
const article = await fetchResource(`articles/${ctx.params.name}.md`);
if (!article) {
return ctx.renderNotFound();
}
@@ -26,34 +27,39 @@ export default function Greet(
) {
const { article, session } = props.data;
const { author = "", date = "" } = article.meta;
const { author = "", date = "", articleBody = "" } = article?.content || {};
const content = renderMarkdown(
removeImage(article.content, article.meta.image),
removeImage(articleBody, article.content.image),
);
console.log({ article });
return (
<MainLayout
url={props.url}
title={`Article > ${article.name}`}
title={`Article > ${article.content.headline}`}
context={article}
>
<RedirectSearchHandler />
<KMenu type="main" context={{ type: "article" }} />
<MetaTags resource={article} />
<PageHero image={article.meta.image} thumbnail={article.meta.thumbnail}>
<PageHero
image={article.content.image}
thumbnail={article.content.thumbnail}
>
<PageHero.Header>
<PageHero.BackLink href="/articles" />
{session && (
<PageHero.EditLink
href={`https://notes.max-richter.dev/Media/articles/${article.id}`}
href={`https://notes.max-richter.dev/resources/articles/${article.name}`}
/>
)}
</PageHero.Header>
<PageHero.Footer>
<PageHero.Title link={article.meta.link}>
{article.name}
<PageHero.Title link={article.content.url}>
{article.content.headline}
</PageHero.Title>
<PageHero.Subline
entries={[
@@ -64,20 +70,20 @@ export default function Greet(
date.toString(),
]}
>
{article.meta.rating && <Star rating={article.meta.rating} />}
{article.content.rating && <Star rating={article.content.rating} />}
</PageHero.Subline>
</PageHero.Footer>
</PageHero>
{article.tags.length > 0 && (
{article.content?.tags?.length > 0 && (
<>
<br />
<HashTags tags={article.tags} />
<HashTags tags={article.content.tags} />
</>
)}
<div class="px-8 text-white mt-10">
{isYoutubeLink(article.meta.link) && (
<YoutubePlayer link={article.meta.link} />
{isYoutubeLink(article.content.url) && (
<YoutubePlayer link={article.content.url} />
)}
<pre
class="whitespace-break-spaces markdown-body"
@@ -85,7 +91,7 @@ export default function Greet(
data-dark-theme="dark"
dangerouslySetInnerHTML={{ __html: content || "" }}
>
{content||""}
{content || ""}
</pre>
</div>
</MainLayout>

View File

@@ -1,6 +1,6 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { Article, getAllArticles } from "@lib/resource/articles.ts";
import { Article } from "@lib/resource/articles.ts";
import { KMenu } from "@islands/KMenu.tsx";
import { Grid } from "@components/Grid.tsx";
import { IconArrowLeft } from "@components/icons.tsx";
@@ -9,12 +9,14 @@ 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/resources.ts";
export const handler: Handlers<
{ articles: Article[] | null; searchResults?: GenericResource[] }
> = {
async GET(req, ctx) {
const articles = await getAllArticles();
const { content: articles } = await fetchResource("articles");
const searchParams = parseResourceUrl(req.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["article"] });

View File

@@ -4,7 +4,7 @@ import { PageProps } from "$fresh/server.ts";
import { resources } from "@lib/resources.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import "@lib/telegram.ts";
// import "@lib/telegram.ts";
export default function Home(props: PageProps) {
return (
@@ -22,11 +22,10 @@ export default function Home(props: PageProps) {
<Card
title={`${m.name}`}
backgroundSize={80}
image={`${
m.emoji.endsWith(".png")
image={`${m.emoji.endsWith(".png")
? `/emojis/${encodeURIComponent(m.emoji)}`
: "/placeholder.svg"
}`}
}`}
link={m.link}
/>
);

View File

@@ -1,7 +1,6 @@
import { PageProps, RouteContext } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { getMovie, Movie } from "@lib/resource/movies.ts";
import { HashTags } from "@components/HashTags.tsx";
import { Movie } from "@lib/resource/movies.ts";
import { removeImage, renderMarkdown } from "@lib/documents.ts";
import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
@@ -9,22 +8,24 @@ import { Recommendations } from "@islands/Recommendations.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/resources.ts";
export default async function Greet(
props: PageProps<{ movie: Movie; session: Record<string, string> }>,
ctx: RouteContext,
) {
const movie = await getMovie(ctx.params.name);
const movie = await fetchResource(`movies/${ctx.params.name}.md`);
const session = ctx.state.session;
if (!movie) {
return ctx.renderNotFound();
}
const { author = "", date = "" } = movie.meta;
const { author = "", date = "" } = movie.content;
const content = renderMarkdown(
removeImage(movie.description || "", movie.meta.image),
removeImage(movie.content.reviewBody || "", movie.content.image),
);
return (
@@ -33,14 +34,14 @@ export default async function Greet(
<KMenu type="main" context={movie} />
<MetaTags resource={movie} />
<PageHero
image={movie.meta.image}
thumbnail={movie.meta.thumbnail}
image={movie.content.image}
thumbnail={movie.content.thumbnail}
>
<PageHero.Header>
<PageHero.BackLink href="/movies" />
{session && (
<PageHero.EditLink
href={`https://notes.max-richter.dev/Media/movies/${movie.id}`}
href={`https://notes.max-richter.dev/resources/movies/${movie.name}`}
/>
)}
</PageHero.Header>
@@ -49,13 +50,17 @@ export default async function Greet(
<PageHero.Subline
entries={[
author && {
title: author,
href: `/?q=${encodeURIComponent(author)}`,
title: author?.name,
href: `/?q=${encodeURIComponent(author?.name)}`,
},
date.toString(),
]}
>
{movie.meta.rating && <Star rating={movie.meta.rating} />}
{movie.content.reviewRating && (
<Star
rating={parseRating(movie.content.reviewRating?.ratingValue)}
/>
)}
</PageHero.Subline>
</PageHero.Footer>
</PageHero>
@@ -65,14 +70,8 @@ export default async function Greet(
type="movie"
/>
)}
{movie.tags.length > 0 && (
<>
<br />
<HashTags tags={movie.tags} />
</>
)}
<div class="px-8 text-white mt-10">
{movie?.description?.length > 80
{movie?.content?.reviewBody?.length > 80
? <h2 class="text-4xl font-bold mb-4">Review</h2>
: <></>}
<pre

View File

@@ -1,25 +1,26 @@
import { MainLayout } from "@components/layouts/main.tsx";
import { getAllMovies, Movie } from "@lib/resource/movies.ts";
import { Movie } from "@lib/resource/movies.ts";
import { ResourceCard } from "@components/Card.tsx";
import { Grid } from "@components/Grid.tsx";
import { IconArrowLeft } from "@components/icons.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { GenericResource } from "@lib/types.ts";
import { PageProps } from "$fresh/server.ts";
import { fetchResource } from "@lib/resources.ts";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
export default async function Greet(
props: PageProps<
{ movies: Movie[] | null; searchResults: GenericResource[] }
>,
) {
const allMovies = await getAllMovies();
const { content: allMovies } = await fetchResource("movies");
const searchParams = parseResourceUrl(props.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["movie"] });
const movies = allMovies.sort((a, b) =>
a?.meta?.rating > b?.meta?.rating ? -1 : 1
a?.content?.reviewRating?.ratingValue > b?.content?.reviewRating?.ratingValue ? -1 : 1
);
return (

View File

@@ -3,7 +3,7 @@ import { IngredientsList } from "@islands/IngredientsList.tsx";
import { MainLayout } from "@components/layouts/main.tsx";
import Counter from "@islands/Counter.tsx";
import { Signal, useSignal } from "@preact/signals";
import { getRecipe, Recipe } from "@lib/resource/recipes.ts";
import { Recipe } from "@lib/recipeSchema.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import PageHero from "@components/PageHero.tsx";
@@ -11,11 +11,12 @@ import { Star } from "@components/Stars.tsx";
import { renderMarkdown } from "@lib/documents.ts";
import { isValidRecipe } from "@lib/recipeSchema.ts";
import { MetaTags } from "@components/MetaTags.tsx";
import { fetchResource } from "@lib/resources.ts";
export const handler: Handlers<{ recipe: Recipe; session: unknown } | null> = {
async GET(_, ctx) {
try {
const recipe = await getRecipe(ctx.params.name);
const recipe = await fetchResource(`recipes/${ctx.params.name}.md`);
if (!recipe) {
return ctx.renderNotFound();
}
@@ -38,22 +39,24 @@ function ValidRecipe({
{portion && <Counter count={amount} />}
</div>
<IngredientsList
ingredients={recipe.ingredients}
ingredients={recipe.content.recipeIngredient}
amount={amount}
portion={portion}
/>
<h3 class="text-3xl my-5">Preparation</h3>
<div class="pl-2">
<ol class="list-decimal grid gap-4">
{recipe.instructions && (recipe.instructions.map((instruction) => {
return (
<li
dangerouslySetInnerHTML={{
__html: renderMarkdown(instruction),
}}
/>
);
}))}
{recipe.content.recipeInstructions &&
(recipe.content.recipeInstructions.filter((inst) => !!inst?.length)
.map((instruction) => {
return (
<li
dangerouslySetInnerHTML={{
__html: renderMarkdown(instruction),
}}
/>
);
}))}
</ol>
</div>
</>
@@ -65,35 +68,38 @@ export default function Page(
) {
const { recipe, session } = props.data;
const portion = recipe.meta?.portion;
const portion = recipe.recipeYield;
const amount = useSignal(portion || 1);
const subline = [
recipe?.meta?.time && `Duration ${recipe.meta.time}`,
recipe?.content?.prepTime && `Duration ${recipe?.content?.prepTime}`,
].filter(Boolean) as string[];
return (
<MainLayout
url={props.url}
title={`Recipes > ${recipe.name}`}
title={`Recipes > ${recipe.content?.name}`}
context={recipe}
>
<RedirectSearchHandler />
<KMenu type="main" context={recipe} />
<MetaTags resource={recipe} />
<PageHero image={recipe.meta?.image} thumbnail={recipe.meta?.thumbnail}>
<PageHero
image={recipe.content?.image}
thumbnail={recipe.content?.thumbnail}
>
<PageHero.Header>
<PageHero.BackLink href="/recipes" />
{session && (
<PageHero.EditLink
href={`https://notes.max-richter.dev/Recipes/${recipe.id}`}
href={`https://notes.max-richter.dev/resources/recipes/${recipe.name}`}
/>
)}
</PageHero.Header>
<PageHero.Footer>
<PageHero.Title link={recipe.meta?.link}>
{recipe.name}
<PageHero.Title link={recipe.content?.link}>
{recipe.content.name}
</PageHero.Title>
<PageHero.Subline
entries={subline}
@@ -113,12 +119,9 @@ export default function Page(
/>
)
: (
<div
class="whitespace-break-spaces markdown-body"
dangerouslySetInnerHTML={{
__html: renderMarkdown(recipe?.markdown || ""),
}}
/>
<div class="whitespace-break-spaces markdown-body">
{JSON.stringify(recipe)}
</div>
)}
</div>
</MainLayout>

View File

@@ -1,19 +1,20 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts";
import { Recipe } from "@lib/recipeSchema.ts";
import { Grid } from "@components/Grid.tsx";
import { IconArrowLeft } from "@components/icons.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { GenericResource } from "@lib/types.ts";
import { ResourceCard } from "@components/Card.tsx";
import { fetchResource } from "@lib/resources.ts";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
export const handler: Handlers<
{ recipes: Recipe[] | null; searchResults?: GenericResource[] }
> = {
async GET(req, ctx) {
const recipes = await getAllRecipes();
const { content: recipes } = await fetchResource("recipes");
const searchParams = parseResourceUrl(req.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["recipe"] });
@@ -48,8 +49,8 @@ export default function Greet(
<h3 class="text-2xl text-white font-light">🍽 Recipes</h3>
</header>
<Grid>
{recipes?.map((doc) => {
return <ResourceCard sublink="recipes" res={doc} />;
{recipes?.filter((s) => !!s?.content?.name).map((doc) => {
return <ResourceCard sublink="recipes" key={doc.name} res={doc} />;
})}
</Grid>
</MainLayout>

View File

@@ -2,16 +2,18 @@ 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 { getSeries, Series } from "@lib/resource/series.ts";
import { Series } from "@lib/resource/series.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/resources.ts";
export const handler: Handlers<{ serie: Series; session: unknown }> = {
async GET(_, ctx) {
const serie = await getSeries(ctx.params.name);
const serie = await fetchResource(`series/${ctx.params.name}`);
if (!serie) {
return ctx.renderNotFound();
@@ -25,24 +27,28 @@ export default function Greet(
) {
const { serie, session } = props.data;
const { author = "", date = "" } = serie.meta;
const { author = "", date = "" } = serie.content;
const content = renderMarkdown(
removeImage(serie.description || "", serie.meta.image),
removeImage(serie.description || "", serie.content.image),
);
return (
<MainLayout url={props.url} title={`Serie > ${serie.name}`} context={serie}>
<MainLayout
url={props.url}
title={`Serie > ${serie.content.name}`}
context={serie}
>
<RedirectSearchHandler />
<KMenu type="main" context={serie} />
<MetaTags resource={serie} />
<PageHero image={serie.meta.image} thumbnail={serie.meta.thumbnail}>
<PageHero image={serie.content.image} thumbnail={serie.content.thumbnail}>
<PageHero.Header>
<PageHero.BackLink href="/series" />
{session && (
<PageHero.EditLink
href={`https://notes.max-richter.dev/Media/series/${serie.id}`}
href={`https://notes.max-richter.dev/resources/series/${serie.name}`}
/>
)}
</PageHero.Header>
@@ -57,18 +63,22 @@ export default function Greet(
date.toString(),
]}
>
{serie.meta.rating && <Star rating={serie.meta.rating} />}
{serie.content.reviewRating && (
<Star
rating={parseRating(serie.content.reviewRating.ratingValue)}
/>
)}
</PageHero.Subline>
</PageHero.Footer>
</PageHero>
{serie.tags.length > 0 && (
{serie.content?.tags?.length > 0 && (
<>
<br />
<HashTags tags={serie.tags} />
<HashTags tags={serie.content.tags} />
</>
)}
<div class="px-8 text-white mt-10">
{serie?.description?.length > 80
{serie?.content?.reviewBody?.length > 80
? <h2 class="text-4xl font-bold mb-4">Review</h2>
: <></>}
<pre

View File

@@ -2,18 +2,19 @@ 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 { getAllSeries, Series } from "@lib/resource/series.ts";
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 { parseResourceUrl, searchResource } from "@lib/search.ts";
import { GenericResource } from "@lib/types.ts";
import { fetchResource } from "@lib/resources.ts";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
export const handler: Handlers<
{ series: Series[] | null; searchResults?: GenericResource[] }
> = {
async GET(req, ctx) {
const series = await getAllSeries();
const { content: series } = await fetchResource("series");
const searchParams = parseResourceUrl(req.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["series"] });