diff --git a/components/Card.tsx b/components/Card.tsx index 8774cfb..19d44a2 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -2,15 +2,15 @@ import { isYoutubeLink } from "@lib/string.ts"; import { IconBrandYoutube } from "@components/icons.tsx"; export function Card( - { link, title, image, backgroundSize = 100 }: { + { link, title, image, thumbnail, backgroundSize = 100 }: { backgroundSize?: number; + thumbnail?: string; link?: string; title?: string; image?: string; }, ) { const backgroundStyle = { - backgroundImage: `url(${image})`, backgroundSize: "cover", boxShadow: "0px -60px 90px black inset, 0px 10px 20px #fff1 inset", }; @@ -25,20 +25,32 @@ export function Card( - {!image?.includes("placeholder.svg") && false && - ( - - )} -
+ {true && ( + + )} +
{/* Recipe Card content */}
diff --git a/components/Image.tsx b/components/Image.tsx index a9137b9..f03f8a8 100644 --- a/components/Image.tsx +++ b/components/Image.tsx @@ -6,20 +6,34 @@ const Image = ( class: string; src: string; alt?: string; + thumbnail?: string; width?: number | string; height?: number | string; style?: CSS.HtmlAttributes; }, ) => { return ( - {props.alt} + + {props.alt} + ); }; diff --git a/components/MovieCard.tsx b/components/MovieCard.tsx index e2feb45..e96c467 100644 --- a/components/MovieCard.tsx +++ b/components/MovieCard.tsx @@ -15,6 +15,7 @@ export function MovieCard( return ( diff --git a/components/RecipeCard.tsx b/components/RecipeCard.tsx index 2d4a491..1ff0e89 100644 --- a/components/RecipeCard.tsx +++ b/components/RecipeCard.tsx @@ -13,6 +13,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) { ); diff --git a/components/RecipeHero.tsx b/components/RecipeHero.tsx index 76284ad..346d1dd 100644 --- a/components/RecipeHero.tsx +++ b/components/RecipeHero.tsx @@ -12,7 +12,10 @@ export function RecipeHero( backlink: string; subline?: string[]; editLink?: string; - data: { meta?: { image?: string; link?: string }; name: string }; + data: { + meta?: { thumbnail?: string; image?: string; link?: string }; + name: string; + }; }, ) { const { meta: { image } = {} } = data; @@ -31,6 +34,7 @@ export function RecipeHero( ( Recipe Banner { - console.log("kMenu", { key: ev.key }); if (ev.key === "k") { if (ev?.target?.nodeName == "INPUT") { return; diff --git a/islands/KMenu/commands/add_series_infos.ts b/islands/KMenu/commands/add_series_infos.ts index e533497..c168a22 100644 --- a/islands/KMenu/commands/add_series_infos.ts +++ b/islands/KMenu/commands/add_series_infos.ts @@ -8,7 +8,6 @@ export const addSeriesInfo: MenuEntry = { meta: "", icon: "IconReportSearch", cb: async (state, context) => { - console.log({ state, context }); state.activeState.value = "loading"; const series = context as Series; @@ -28,7 +27,6 @@ export const addSeriesInfo: MenuEntry = { title: `${m.name || m.original_name} released ${m.first_air_date}`, cb: async () => { state.activeState.value = "loading"; - console.log({ m }); await fetch(`/api/series/enhance/${series.name}/`, { method: "POST", body: JSON.stringify({ tmdbId: m.id }), diff --git a/lib/cache/cache.ts b/lib/cache/cache.ts index ad47448..526d975 100644 --- a/lib/cache/cache.ts +++ b/lib/cache/cache.ts @@ -92,17 +92,13 @@ export function keys(prefix: string) { return cache.keys(prefix); } -export async function set( +export function set( id: string, content: T, options?: RedisOptions, ) { log.debug("storing ", { id }); - const res = await cache.set(id, content); - if (options?.expires) { - await expire(id, options.expires); - } - return res; + return cache.set(id, content, { ex: options?.expires || undefined }); } export const cacheFunction = async Promise)>( diff --git a/lib/cache/image.ts b/lib/cache/image.ts index 58fa7a9..9ebc154 100644 --- a/lib/cache/image.ts +++ b/lib/cache/image.ts @@ -1,29 +1,79 @@ -import { hash } from "@lib/string.ts"; +import { hash, isLocalImage } from "@lib/string.ts"; import * as cache from "@lib/cache/cache.ts"; -import { ImageMagick } from "https://deno.land/x/imagemagick_deno@0.0.25/mod.ts"; +import { + ImageMagick, + MagickGeometry, +} from "https://deno.land/x/imagemagick_deno@0.0.25/mod.ts"; import { createLogger } from "@lib/log.ts"; +import { generateThumbhash } from "@lib/thumbhash.ts"; +import { SILVERBULLET_SERVER } from "@lib/env.ts"; type ImageCacheOptions = { url: string; width: number; height: number; mediaType?: string; + suffix?: string; }; const CACHE_KEY = "images"; const log = createLogger("cache/image"); -function getCacheKey({ url: _url, width, height }: ImageCacheOptions) { - const url = new URL(_url); +function getCacheKey( + { url: _url, width, height, suffix }: ImageCacheOptions, +) { + const isLocal = isLocalImage(_url); + const url = new URL(isLocal ? `${SILVERBULLET_SERVER}/${_url}` : _url); + + const _suffix = suffix || `${width}:${height}`; + return `${CACHE_KEY}:${url.hostname}:${ url.pathname.replaceAll("/", ":") - }:${width}:${height}` + }:${_suffix}` .replace( "::", ":", ); } +export function createThumbhash( + image: Uint8Array, + url: string, +): Promise { + return new Promise((res, rej) => { + try { + ImageMagick.read(image.slice(), (_image) => { + _image.resize(new MagickGeometry(100, 100)); + _image.getPixels((pixels) => { + const bytes = pixels.toByteArray( + 0, + 0, + _image.width, + _image.height, + "RGBA", + ); + if (!bytes) return; + const hash = generateThumbhash(bytes, _image.width, _image.height); + if (hash) { + cache.set( + getCacheKey({ + url, + suffix: "thumbnail", + width: _image.width, + height: _image.height, + }), + hash, + ); + res(hash); + } + }); + }); + } catch (err) { + rej(err); + } + }); +} + function verifyImage( imageBuffer: Uint8Array, ) { @@ -38,6 +88,18 @@ function verifyImage( }); } +export function getThumbhash({ url }: { url: string }) { + return cache.get( + getCacheKey({ + url, + suffix: "thumbnail", + width: 200, + height: 200, + }), + true, + ); +} + export async function getImage({ url, width, height }: ImageCacheOptions) { const cacheKey = getCacheKey({ url, width, height }); diff --git a/lib/crud.ts b/lib/crud.ts index fbc936b..11dd9f3 100644 --- a/lib/crud.ts +++ b/lib/crud.ts @@ -5,10 +5,40 @@ import { transformDocument, } from "@lib/documents.ts"; import { Root } from "https://esm.sh/remark-frontmatter@4.0.1"; +import { getThumbhash } from "@lib/cache/image.ts"; + +type Resource = { + name: string; + id: string; + meta: { + image?: string; + author?: string; + thumbnail?: string; + }; +}; + +export async function addThumbnailToResource( + res: T, +): Promise { + const imageUrl = res?.meta?.image; + if (!imageUrl) return res; + const thumbhash = await getThumbhash({ url: imageUrl }); + if (!thumbhash) return res; + const base64String = btoa(String.fromCharCode(...thumbhash)); + return { + ...res, + meta: { + ...res?.meta, + thumbnail: base64String, + }, + }; +} export function createCrud( - { prefix, parse }: { + { prefix, parse, render, hasThumbnails = false }: { prefix: string; + hasThumbnails?: boolean; + render?: (doc: T) => string; parse: (doc: string, id: string) => T; }, ) { @@ -19,11 +49,27 @@ export function createCrud( async function read(id: string) { const path = pathFromId(id); const content = await getDocument(path); - return parse(content, id); + const res = parse(content, id); + + if (hasThumbnails) { + return addThumbnailToResource(res); + } + + return res; } - function create(id: string, content: string | ArrayBuffer) { + function create(id: string, content: string | ArrayBuffer | T) { const path = pathFromId(id); - return createDocument(path, content); + if ( + typeof content === "string" || content instanceof ArrayBuffer + ) { + return createDocument(path, content); + } + + if (render) { + return createDocument(path, render(content)); + } + + throw new Error("No renderer defined for " + prefix + " CRUD"); } async function update(id: string, updater: (r: Root) => Root) { diff --git a/lib/log.ts b/lib/log.ts index d692af1..607530a 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -5,7 +5,7 @@ enum LOG_LEVEL { ERROR, } let longestScope = 0; -let logLevel = LOG_LEVEL.DEBUG; +let logLevel = LOG_LEVEL.WARN; export function setLogLevel(level: LOG_LEVEL) { logLevel = level; @@ -49,5 +49,3 @@ export function createLogger(scope: string, _options?: LoggerOptions) { warn, }; } - -const log = createLogger(""); diff --git a/lib/resource/articles.ts b/lib/resource/articles.ts index c0ac2d7..4bb8fed 100644 --- a/lib/resource/articles.ts +++ b/lib/resource/articles.ts @@ -4,6 +4,7 @@ import { createCrud } from "@lib/crud.ts"; import { stringify } from "$std/yaml/stringify.ts"; import { extractHashTags, formatDate } from "@lib/string.ts"; import { fixRenderedMarkdown } from "@lib/helpers.ts"; +import { getThumbhash } from "@lib/cache/image.ts"; export type Article = { id: string; @@ -15,6 +16,7 @@ export type Article = { status: "finished" | "not-finished"; date: Date; link: string; + thumbnail?: string; image?: string; author?: string; rating?: number; @@ -24,6 +26,7 @@ export type Article = { const crud = createCrud
({ prefix: "Media/articles/", parse: parseArticle, + hasThumbnails: true, }); function renderArticle(article: Article) { diff --git a/lib/resource/movies.ts b/lib/resource/movies.ts index 4b0fe89..fec206b 100644 --- a/lib/resource/movies.ts +++ b/lib/resource/movies.ts @@ -1,4 +1,4 @@ -import { parseDocument, renderMarkdown } from "@lib/documents.ts"; +import { parseDocument } from "@lib/documents.ts"; import { parse, stringify } from "yaml"; import { createCrud } from "@lib/crud.ts"; import { extractHashTags, formatDate } from "@lib/string.ts"; @@ -13,6 +13,7 @@ export type Movie = { meta: { date: Date; image: string; + thumbnail?: string; author: string; rating: number; status: "not-seen" | "watch-again" | "finished"; @@ -98,6 +99,7 @@ export function parseMovie(original: string, id: string): Movie { const crud = createCrud({ prefix: "Media/movies/", parse: parseMovie, + hasThumbnails: true, }); export const getMovie = crud.read; diff --git a/lib/resource/recipes.ts b/lib/resource/recipes.ts index 404f2fa..da37a30 100644 --- a/lib/resource/recipes.ts +++ b/lib/resource/recipes.ts @@ -37,6 +37,7 @@ export type Recipe = { rating?: number; portion?: number; author?: string; + thumbnail?: string; }; }; @@ -186,6 +187,7 @@ export function parseRecipe(original: string, id: string): Recipe { const crud = createCrud({ prefix: `Recipes/`, parse: parseRecipe, + hasThumbnails: true, }); export const getAllRecipes = crud.readAll; diff --git a/lib/resource/series.ts b/lib/resource/series.ts index dbbed0c..120beb5 100644 --- a/lib/resource/series.ts +++ b/lib/resource/series.ts @@ -3,6 +3,7 @@ import { parse, stringify } from "yaml"; import { createCrud } from "@lib/crud.ts"; import { extractHashTags, formatDate } from "@lib/string.ts"; import { fixRenderedMarkdown } from "@lib/helpers.ts"; +import { getThumbhash } from "@lib/cache/image.ts"; export type Series = { id: string; @@ -15,6 +16,7 @@ export type Series = { image: string; author: string; rating: number; + thumbnail?: string; status: "not-seen" | "watch-again" | "finished"; }; }; @@ -98,9 +100,23 @@ export function parseSeries(original: string, id: string): Series { const crud = createCrud({ prefix: "Media/series/", parse: parseSeries, + hasThumbnails: true, }); -export const getSeries = crud.read; +export const getSeries = (id: string) => + crud.read(id).then(async (serie) => { + const imageUrl = serie.meta?.image; + if (!imageUrl) return serie; + const thumbhash = await getThumbhash({ url: imageUrl }); + if (!thumbhash) return serie; + return { + ...serie, + meta: { + ...serie.meta, + thumbnail: btoa(String.fromCharCode(...thumbhash)), + }, + }; + }); export const getAllSeries = crud.readAll; export const createSeries = (series: Series) => { const content = renderSeries(series); diff --git a/lib/thumbhash.ts b/lib/thumbhash.ts new file mode 100644 index 0000000..5b44d12 --- /dev/null +++ b/lib/thumbhash.ts @@ -0,0 +1,17 @@ +import * as thumbhash from "https://esm.sh/thumbhash@0.1.1"; + +export function generateThumbhash(buffer: Uint8Array, w: number, h: number) { + return thumbhash.rgbaToThumbHash(w, h, buffer); +} + +export function generateDataURL(hash: string) { + const decodedString = atob(hash); + + // Create Uint8Array from decoded string + const uint8Array = new Uint8Array(decodedString.length); + for (let i = 0; i < decodedString.length; i++) { + uint8Array[i] = decodedString.charCodeAt(i); + } + + return thumbhash.thumbHashToDataURL(uint8Array); +} diff --git a/routes/_app.tsx b/routes/_app.tsx index 66ce537..8e26663 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -29,6 +29,7 @@ export default function App({ Component }: AppProps) { +