diff --git a/components/Card.tsx b/components/Card.tsx new file mode 100644 index 0000000..2b19fa9 --- /dev/null +++ b/components/Card.tsx @@ -0,0 +1,29 @@ +import { Recipe } from "../lib/recipes.ts"; + +export function Card( + { link, title, image }: { link?: string; title?: string; image?: string }, +) { + return ( + +
+
+ {/* Recipe Card content */} +
+
+ {title} +
+
+
+ + ); +} diff --git a/components/RecipeCard.tsx b/components/RecipeCard.tsx index a67349f..37a60ff 100644 --- a/components/RecipeCard.tsx +++ b/components/RecipeCard.tsx @@ -1,29 +1,18 @@ -import { Recipe } from "../lib/recipes.ts"; +import { Card } from "@components/Card.tsx"; +import { Recipe } from "@lib/recipes.ts"; export function RecipeCard({ recipe }: { recipe: Recipe }) { + const { meta: { image = "Recipes/images/placeholder.jpg" } = {} } = recipe; + + const imageUrl = image.startsWith("Recipes/images/") + ? `/api/images?image=${image}&width=200&height=200` + : image; + return ( - -
-
- {/* Recipe Card content */} -
-
- {recipe.name} -
-
-
- + ); } diff --git a/components/RecipeHero.tsx b/components/RecipeHero.tsx index 244bca0..b939e4d 100644 --- a/components/RecipeHero.tsx +++ b/components/RecipeHero.tsx @@ -1,10 +1,16 @@ -import { Recipe } from "../lib/recipes.ts"; +import { Recipe } from "@lib/recipes.ts"; export function RecipeHero({ recipe }: { recipe: Recipe }) { + const { meta: { image = "Recipes/images/placeholder.jpg" } = {} } = recipe; + + const imageUrl = image.startsWith("Recipes/images/") + ? `/api/images?image=${image}&width=800` + : image; + return (
Recipe Banner @@ -18,11 +24,11 @@ export function RecipeHero({ recipe }: { recipe: Recipe }) {
-
-

+
+

{recipe.name}

diff --git a/deno.json b/deno.json index d38f81b..ea81c4f 100755 --- a/deno.json +++ b/deno.json @@ -2,6 +2,7 @@ "lock": false, "tasks": { "start": "deno run -A --watch=static/,routes/ dev.ts", + "debug": "deno run --inspect-wait -A main.ts", "update": "deno run -A -r https://fresh.deno.dev/update ." }, "lint": { diff --git a/fresh.gen.ts b/fresh.gen.ts index 34517ce..4ffeaa6 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -4,9 +4,9 @@ import * as $0 from "./routes/_404.tsx"; import * as $1 from "./routes/_app.tsx"; -import * as $2 from "./routes/api/index.ts"; -import * as $3 from "./routes/api/recipes/[name].ts"; -import * as $4 from "./routes/api/recipes/images/[image].ts"; +import * as $2 from "./routes/api/images/index.ts"; +import * as $3 from "./routes/api/index.ts"; +import * as $4 from "./routes/api/recipes/[name].ts"; import * as $5 from "./routes/api/recipes/index.ts"; import * as $6 from "./routes/index.tsx"; import * as $7 from "./routes/recipes/[name].tsx"; @@ -17,9 +17,9 @@ const manifest = { routes: { "./routes/_404.tsx": $0, "./routes/_app.tsx": $1, - "./routes/api/index.ts": $2, - "./routes/api/recipes/[name].ts": $3, - "./routes/api/recipes/images/[image].ts": $4, + "./routes/api/images/index.ts": $2, + "./routes/api/index.ts": $3, + "./routes/api/recipes/[name].ts": $4, "./routes/api/recipes/index.ts": $5, "./routes/index.tsx": $6, "./routes/recipes/[name].tsx": $7, diff --git a/lib/cache.ts b/lib/cache.ts deleted file mode 100644 index 32becce..0000000 --- a/lib/cache.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from "https://deno.land/x/redis/mod.ts"; - -const REDIS_HOST = Deno.env.get("REDIS_HOST"); -const REDIS_PASS = Deno.env.get("REDIS_PASS"); -const REDIS_PORT = Deno.env.get("REDIS_PORT"); - -async function createCache() { - if (REDIS_HOST && REDIS_PASS) { - const client = await connect({ - password: REDIS_PASS, - hostname: REDIS_HOST, - port: REDIS_PORT || 6379, - }); - console.log("COnnected to redis"); - return client; - } - - return new Map(); -} - -const cache = await createCache(); - -export async function get(id: string) { - return await cache.get(id); -} - -export async function set(id: string, content: any) { - return await cache.set(id, content); -} diff --git a/lib/cache/cache.ts b/lib/cache/cache.ts new file mode 100644 index 0000000..c5aa77d --- /dev/null +++ b/lib/cache/cache.ts @@ -0,0 +1,42 @@ +import { + connect, + Redis, + RedisConnectOptions, + RedisValue, +} from "https://deno.land/x/redis@v0.31.0/mod.ts"; + +const REDIS_HOST = Deno.env.get("REDIS_HOST"); +const REDIS_PASS = Deno.env.get("REDIS_PASS") || ""; +const REDIS_PORT = Deno.env.get("REDIS_PORT"); + +async function createCache(): Promise | Redis> { + if (REDIS_HOST) { + const conf: RedisConnectOptions = { + hostname: REDIS_HOST, + port: REDIS_PORT || 6379, + }; + if (REDIS_PASS) { + conf.password = REDIS_PASS; + } + const client = await connect(conf); + console.log("Connected to redis"); + return client; + } + + return new Map(); +} + +const cache = await createCache(); + +export async function get(id: string, binary = false) { + if (binary && !(cache instanceof Map)) { + return await cache.sendCommand("GET", [id], { + returnUint8Arrays: true, + }) as T; + } + return await cache.get(id) as T; +} + +export async function set(id: string, content: T) { + return await cache.set(id, content); +} diff --git a/lib/cache/documents.ts b/lib/cache/documents.ts new file mode 100644 index 0000000..8c4754c --- /dev/null +++ b/lib/cache/documents.ts @@ -0,0 +1,57 @@ +import { Document } from "@lib/documents.ts"; +import * as cache from "@lib/cache/cache.ts"; + +type DocumentsCache = { + lastUpdated: number; + documents: Document[]; +}; + +const CACHE_INTERVAL = 5000; // 5 seconds; +const CACHE_KEY = "documents"; + +export async function getDocuments() { + const docs = await cache.get(CACHE_KEY); + if (!docs) return; + + if (Date.now() > docs.lastUpdated + CACHE_INTERVAL) { + return; + } + + return docs.documents; +} + +export function setDocuments(documents: Document[]) { + return cache.set( + CACHE_KEY, + JSON.stringify({ + lastUpdated: Date.now(), + documents, + }), + ); +} + +type DocumentCache = { + lastUpdated: number; + content: string; +}; + +export async function getDocument(id: string) { + const doc = await cache.get(CACHE_KEY + "/" + id); + if (!doc) return; + + if (Date.now() > doc.lastUpdated + CACHE_INTERVAL) { + return; + } + + return doc.content; +} + +export async function setDocument(id: string, content: string) { + await cache.set( + CACHE_KEY + "/" + id, + JSON.stringify({ + lastUpdated: Date.now(), + content, + }), + ); +} diff --git a/lib/cache/image.ts b/lib/cache/image.ts new file mode 100644 index 0000000..abbb4d1 --- /dev/null +++ b/lib/cache/image.ts @@ -0,0 +1,63 @@ +import { hash } from "@lib/hash.ts"; +import * as cache from "@lib/cache/cache.ts"; + +type ImageCacheOptions = { + url: string; + width: number; + height: number; + mediaType?: string; +}; + +const CACHE_KEY = "images"; + +function getCacheKey({ url: _url, width, height }: ImageCacheOptions) { + const url = new URL(_url); + + return `${CACHE_KEY}/${url.hostname}/${url.pathname}/${width}/${height}` + .replace( + "//", + "/", + ); +} + +export async function getImage({ url, width, height }: ImageCacheOptions) { + const cacheKey = getCacheKey({ url, width, height }); + + const pointerCacheRaw = await cache.get(cacheKey); + if (!pointerCacheRaw) return; + + const pointerCache = typeof pointerCacheRaw === "string" + ? JSON.parse(pointerCacheRaw) + : pointerCacheRaw; + + const imageContent = await cache.get(pointerCache.id, true); + if (!imageContent) return; + + return { + ...pointerCache, + buffer: imageContent, + }; +} + +export async function setImage( + buffer: Uint8Array, + { url, width, height, mediaType }: ImageCacheOptions, +) { + const clone = new Uint8Array(buffer); + + const cacheKey = getCacheKey({ url, width, height }); + const pointerId = await hash(cacheKey); + + await cache.set(pointerId, clone); + + await cache.set( + cacheKey, + JSON.stringify({ + id: pointerId, + url, + width, + height, + mediaType, + }), + ); +} diff --git a/lib/documents.ts b/lib/documents.ts index 6e3bd85..c74c2ca 100644 --- a/lib/documents.ts +++ b/lib/documents.ts @@ -2,6 +2,7 @@ import { unified } from "npm:unified"; import remarkParse from "npm:remark-parse"; import remarkFrontmatter from "https://esm.sh/remark-frontmatter@4"; import { parse } from "https://deno.land/std@0.194.0/yaml/mod.ts"; +import * as cache from "@lib/cache/documents.ts"; const SILVERBULLET_SERVER = Deno.env.get("SILVERBULLET_SERVER"); @@ -18,6 +19,9 @@ export function parseFrontmatter(yaml: string) { } export async function getDocuments(): Promise { + const cachedDocuments = await cache.getDocuments(); + if (cachedDocuments) return cachedDocuments; + const headers = new Headers(); headers.append("Accept", "application/json"); @@ -25,12 +29,22 @@ export async function getDocuments(): Promise { headers: headers, }); - return response.json(); + const documents = await response.json(); + cache.setDocuments(documents); + + return documents; } export async function getDocument(name: string): Promise { + const cachedDocument = await cache.getDocument(name); + if (cachedDocument) return cachedDocument; + const response = await fetch(SILVERBULLET_SERVER + "/" + name); - return await response.text(); + const text = await response.text(); + + cache.setDocument(name, text); + + return text; } export function parseDocument(doc: string) { diff --git a/lib/hash.ts b/lib/hash.ts new file mode 100644 index 0000000..3db7dff --- /dev/null +++ b/lib/hash.ts @@ -0,0 +1,9 @@ +export async function hash(message: string) { + const data = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join( + "", + ); + return hashHex; +} diff --git a/lib/recipes.ts b/lib/recipes.ts index 2fa7f6d..1a87d03 100644 --- a/lib/recipes.ts +++ b/lib/recipes.ts @@ -4,7 +4,7 @@ import { getTextOfRange, parseDocument, parseFrontmatter, -} from "./documents.ts"; +} from "@lib/documents.ts"; import { parseIngredient } from "npm:parse-ingredient"; @@ -161,10 +161,7 @@ export function parseRecipe(original: string, id: string): Recipe { return { id, - meta: { - ...meta, - image: meta?.image?.replace(/^Recipes\/images/, "/api/recipes/images"), - }, + meta, name, description, ingredients, diff --git a/routes/api/recipes/images/[image].ts b/routes/api/images/index.ts similarity index 78% rename from routes/api/recipes/images/[image].ts rename to routes/api/images/index.ts index 9b5bab4..c9b32cf 100644 --- a/routes/api/recipes/images/[image].ts +++ b/routes/api/images/index.ts @@ -5,7 +5,7 @@ import { MagickGeometry, } from "https://deno.land/x/imagemagick_deno@0.0.14/mod.ts"; import { parseMediaType } from "https://deno.land/std@0.175.0/media_types/parse_media_type.ts"; -import * as cache from "@lib/cache.ts"; +import * as cache from "@lib/cache/image.ts"; await initializeImageMagick(); @@ -56,6 +56,11 @@ function modifyImage( } function parseParams(reqUrl: URL) { + const image = reqUrl.searchParams.get("image")?.replace(/^\//, ""); + if (image == null) { + return "Missing 'image' query parameter."; + } + const height = Number(reqUrl.searchParams.get("height")) || 0; const width = Number(reqUrl.searchParams.get("width")) || 0; if (height === 0 && width === 0) { @@ -69,50 +74,36 @@ function parseParams(reqUrl: URL) { return `Width and height cannot exceed ${maxDimension}.`; } return { + image, height, width, }; } -async function getImageResponse( - remoteImage: { buffer: Uint8Array; mediaType: string }, - params: { width: number; height: number }, -): Promise { - const modifiedImage = await modifyImage(remoteImage.buffer, { - ...params, - mode: "resize", - }); - - const response = new Response(modifiedImage, { - headers: { - "Content-Type": remoteImage.mediaType, - }, - }); - - return response; -} - export const handler = async ( _req: Request, _ctx: HandlerContext, ): Promise => { - const imageUrl = Deno.env.get("SILVERBULLET_SERVER") + "/Recipes/images/" + - _ctx.params.image; - const url = new URL(_req.url); const params = parseParams(url); - if (typeof params === "string") { return new Response(params, { status: 400 }); } - const imageId = `${imageUrl}.${params.width}.${params.height}`; - const cachedResponse = await cache.get(imageId); + const imageUrl = Deno.env.get("SILVERBULLET_SERVER") + "/" + params.image; + + const cachedResponse = await cache.getImage({ + url: imageUrl, + width: params.width, + height: params.height, + }); if (cachedResponse) { - const _r = await cachedResponse; - console.log({ cachedResponse, _r }); - return (await cachedResponse).clone(); + return new Response(cachedResponse.buffer.slice(), { + headers: { + "Content-Type": cachedResponse.mediaType, + }, + }); } const remoteImage = await getRemoteImage(imageUrl); @@ -120,9 +111,21 @@ export const handler = async ( return new Response(remoteImage, { status: 400 }); } - const response = getImageResponse(remoteImage, params); + const modifiedImage = await modifyImage(remoteImage.buffer, { + ...params, + mode: "resize", + }); - await cache.set(imageId, response); + cache.setImage(modifiedImage, { + url: imageUrl, + width: params.width, + height: params.height, + mediaType: remoteImage.mediaType, + }); - return response; + return new Response(modifiedImage, { + headers: { + "Content-Type": remoteImage.mediaType, + }, + }); }; diff --git a/routes/index.tsx b/routes/index.tsx index b002168..a8b7075 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,6 +1,8 @@ import { Head } from "$fresh/runtime.ts"; import { useSignal } from "@preact/signals"; import Counter from "@islands/Counter.tsx"; +import { MainLayout } from "@components/layouts/main.tsx"; +import { Card } from "@components/Card.tsx"; export default function Home() { const count = useSignal(3); @@ -9,23 +11,15 @@ export default function Home() { app -
-
- the fresh logo: a sliced lemon dripping with juice +
+ -

Welcome to fresh

-

- Try updating this message in the - ./routes/index.tsx file, and refresh. -

-
-
+ ); } diff --git a/routes/recipes/index.tsx b/routes/recipes/index.tsx index d09f759..88730bd 100644 --- a/routes/recipes/index.tsx +++ b/routes/recipes/index.tsx @@ -3,7 +3,7 @@ import { RecipeCard } from "@components/RecipeCard.tsx"; import { MainLayout } from "@components/layouts/main.tsx"; import { Recipe } from "@lib/recipes.ts"; import { getRecipes } from "../api/recipes/index.ts"; - +import IconArrowLeft from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/arrow-left.tsx"; export const handler: Handlers = { async GET(_, ctx) { const recipes = await getRecipes(); @@ -14,6 +14,17 @@ export const handler: Handlers = { export default function Greet(props: PageProps) { return ( +
+ + + Back + + +

Recipes

+
{props.data?.map((doc) => { return ; diff --git a/static/global.css b/static/global.css index 7c270fa..67b393b 100644 --- a/static/global.css +++ b/static/global.css @@ -1,3 +1,17 @@ body { - background: #1F1F1F + background: #1F1F1F; + padding: 0px 20px; +} + +.noisy-gradient::after { + content: ""; + top: 0; + left: 0; + z-index:0; + position: absolute; + opacity: 0.7; + height: 100%; + width: 100%; + background: url(/grainy-gradient.png); + background-size: contain; } diff --git a/static/grainy-gradient.png b/static/grainy-gradient.png new file mode 100644 index 0000000..b072539 Binary files /dev/null and b/static/grainy-gradient.png differ