feat: make author clickable

This commit is contained in:
max_richter 2023-08-12 18:32:56 +02:00
parent d2a02fcf34
commit 2b4173d759
27 changed files with 257 additions and 174 deletions

View File

@ -1,9 +1,11 @@
import { isYoutubeLink } from "@lib/string.ts"; import { isLocalImage, isYoutubeLink } from "@lib/string.ts";
import { IconBrandYoutube } from "@components/icons.tsx"; import { IconBrandYoutube } from "@components/icons.tsx";
import { GenericResource } from "@lib/types.ts";
export function Card( export function Card(
{ link, title, image, thumbnail, backgroundSize = 100 }: { { link, title, image, thumbnail, backgroundColor, backgroundSize = 100 }: {
backgroundSize?: number; backgroundSize?: number;
backgroundColor?: string;
thumbnail?: string; thumbnail?: string;
link?: string; link?: string;
title?: string; title?: string;
@ -12,7 +14,7 @@ export function Card(
) { ) {
const backgroundStyle = { const backgroundStyle = {
backgroundSize: "cover", backgroundSize: "cover",
boxShadow: "0px -60px 90px black inset, 0px 10px 20px #fff1 inset", backgroundColor: backgroundColor,
}; };
if (backgroundSize !== 100) { if (backgroundSize !== 100) {
@ -26,18 +28,20 @@ export function Card(
href={link} href={link}
style={backgroundStyle} style={backgroundStyle}
data-thumb={thumbnail} data-thumb={thumbnail}
class="text-white rounded-3xl shadow-md relative class="text-white rounded-3xl shadow-md relative
lg:w-56 lg:h-56 lg:w-56 lg:h-56
sm:w-48 sm:h-48 sm:w-48 sm:h-48
w-[37vw] h-[37vw]" w-[37vw] h-[37vw]"
> >
{true && ( {true && (
<img <span class="absolute top-0 left-0 overflow-hidden rounded-3xl w-full h-full">
class="absolute rounded-3xl top-0 left-0 object-cover w-full h-full" <img
data-thumb-img class="w-full h-full object-cover"
loading="lazy" data-thumb-img
src={image || "/placeholder.svg"} loading="lazy"
/> src={image || "/placeholder.svg"}
/>
</span>
)} )}
<div <div
class="p-4 flex flex-col justify-between relative z-10" class="p-4 flex flex-col justify-between relative z-10"
@ -48,7 +52,7 @@ export function Card(
width: "calc(100% + 0.5px)", width: "calc(100% + 0.5px)",
marginTop: "-0.5px", marginTop: "-0.5px",
marginLeft: "-0.5px", marginLeft: "-0.5px",
boxShadow: "0px -60px 90px black inset, 0px 10px 20px #fff1 inset", boxShadow: "0px -60px 40px black inset, 0px 10px 20px #fff1 inset",
}} }}
> >
<div> <div>
@ -63,3 +67,23 @@ export function Card(
</a> </a>
); );
} }
export function ResourceCard(
{ res, sublink = "movies" }: { sublink?: string; res: GenericResource },
) {
const { meta: { image = "/placeholder.svg" } = {} } = res;
const imageUrl = isLocalImage(image)
? `/api/images?image=${image}&width=200&height=200`
: image;
return (
<Card
title={res.name}
backgroundColor={res.meta?.average}
thumbnail={res.meta?.thumbnail}
image={imageUrl}
link={`/${sublink}/${res.id}`}
/>
);
}

View File

@ -36,6 +36,7 @@ const Image = (
src: string; src: string;
alt?: string; alt?: string;
thumbnail?: string; thumbnail?: string;
fill?: boolean;
width?: number | string; width?: number | string;
height?: number | string; height?: number | string;
style?: CSS.HtmlAttributes; style?: CSS.HtmlAttributes;
@ -48,10 +49,10 @@ const Image = (
return ( return (
<span <span
style={{ style={{
position: "absolute", position: props.fill ? "absolute" : "",
width: "100%", width: props.fill ? "100%" : "",
height: "100%", height: props.fill ? "100%" : "",
zIndex: -1, zIndex: props.fill ? -1 : "",
}} }}
data-thumb={props.thumbnail} data-thumb={props.thumbnail}
> >

View File

@ -1,23 +0,0 @@
import { Card } from "@components/Card.tsx";
import { Movie } from "@lib/resource/movies.ts";
import { Series } from "@lib/resource/series.ts";
import { isLocalImage } from "@lib/string.ts";
export function MovieCard(
{ movie, sublink = "movies" }: { sublink?: string; movie: Movie | Series },
) {
const { meta: { image = "/placeholder.svg" } = {} } = movie;
const imageUrl = isLocalImage(image)
? `/api/images?image=${image}&width=200&height=200`
: image;
return (
<Card
title={movie.name}
thumbnail={movie.meta.thumbnail}
image={imageUrl}
link={`/${sublink}/${movie.id}`}
/>
);
}

View File

@ -1,20 +0,0 @@
import { Card } from "@components/Card.tsx";
import { Recipe } from "@lib/resource/recipes.ts";
import { isLocalImage } from "@lib/string.ts";
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const { meta: { image = "/placeholder.svg" } = {} } = recipe;
const imageUrl = isLocalImage(image)
? `/api/images?image=${image}&width=200&height=200`
: image;
return (
<Card
title={recipe.name}
image={imageUrl}
thumbnail={recipe?.meta?.thumbnail}
link={`/recipes/${recipe.id}`}
/>
);
}

View File

@ -5,20 +5,20 @@ import {
IconExternalLink, IconExternalLink,
} from "@components/icons.tsx"; } from "@components/icons.tsx";
import Image from "@components/Image.tsx"; import Image from "@components/Image.tsx";
import { GenericResource } from "@lib/types.ts";
export function RecipeHero( export function RecipeHero(
{ data, subline, backlink, editLink }: { { data, subline, backlink, editLink }: {
backlink: string; backlink: string;
subline?: string[]; subline?: (string | { title: string; href: string })[];
editLink?: string; editLink?: string;
data: { data: GenericResource;
meta?: { thumbnail?: string; image?: string; link?: string };
name: string;
};
}, },
) { ) {
const { meta: { image } = {} } = data; const { meta: { image } = {} } = data;
console.log({ meta: data.meta });
return ( return (
<div <div
class={`flex justify-between flex-col relative w-full min-h-[${ class={`flex justify-between flex-col relative w-full min-h-[${
@ -28,6 +28,7 @@ export function RecipeHero(
{image && {image &&
( (
<Image <Image
fill
src={image} src={image}
thumbnail={data.meta?.thumbnail} thumbnail={data.meta?.thumbnail}
alt="Recipe Banner" alt="Recipe Banner"
@ -90,8 +91,14 @@ export function RecipeHero(
class={`relative z-50 flex gap-5 font-sm text-light mt-3`} class={`relative z-50 flex gap-5 font-sm text-light mt-3`}
style={{ color: image ? "#1F1F1F" : "white" }} style={{ color: image ? "#1F1F1F" : "white" }}
> >
{subline.filter((s) => s && s?.length > 1).map((s) => { {subline.filter((s) =>
return <span>{s}</span>; s && (typeof s === "string" ? s?.length > 1 : true)
).map((s) => {
if (typeof s === "string") {
return <span>{s}</span>;
} else {
return <a href={s.href}>{s.title}</a>;
}
})} })}
</div> </div>
)} )}

95
lib/cache/image.ts vendored
View File

@ -1,4 +1,4 @@
import { hash, isLocalImage } from "@lib/string.ts"; import { hash, isLocalImage, rgbToHex } from "@lib/string.ts";
import * as cache from "@lib/cache/cache.ts"; import * as cache from "@lib/cache/cache.ts";
import { import {
ImageMagick, ImageMagick,
@ -8,32 +8,45 @@ import { createLogger } from "@lib/log.ts";
import { generateThumbhash } from "@lib/thumbhash.ts"; import { generateThumbhash } from "@lib/thumbhash.ts";
import { SILVERBULLET_SERVER } from "@lib/env.ts"; import { SILVERBULLET_SERVER } from "@lib/env.ts";
type ImageCacheOptions = { type ImageCacheOptionsBasic = {
url: string; url: string;
mediaType?: string;
};
interface ImageCacheOptionsDimensions extends ImageCacheOptionsBasic {
width: number; width: number;
height: number; height: number;
mediaType?: string; }
suffix?: string;
}; interface ImageCacheOptionsSuffix extends ImageCacheOptionsBasic {
suffix: string;
}
type ImageCacheOptions = ImageCacheOptionsDimensions | ImageCacheOptionsSuffix;
const CACHE_KEY = "images"; const CACHE_KEY = "images";
const log = createLogger("cache/image"); const log = createLogger("cache/image");
function getCacheKey( function getCacheKey(
{ url: _url, width, height, suffix }: ImageCacheOptions, opts: ImageCacheOptions,
) { ) {
const isLocal = isLocalImage(_url); const isLocal = isLocalImage(opts.url);
const url = new URL(isLocal ? `${SILVERBULLET_SERVER}/${_url}` : _url); const url = new URL(
isLocal ? `${SILVERBULLET_SERVER}/${opts.url}` : opts.url,
);
const _suffix = suffix || `${width}:${height}`; const _suffix = "suffix" in opts
? opts.suffix
: `${opts.width}:${opts.height}`;
return `${CACHE_KEY}:${url.hostname}:${ const cacheId = `${CACHE_KEY}:${url.hostname}:${
url.pathname.replaceAll("/", ":") url.pathname.replaceAll("/", ":")
}:${_suffix}` }:${_suffix}`
.replace( .replace(
"::", "::",
":", ":",
); );
return cacheId;
} }
export function createThumbhash( export function createThumbhash(
@ -53,7 +66,21 @@ export function createThumbhash(
"RGBA", "RGBA",
); );
if (!bytes) return; if (!bytes) return;
const hash = generateThumbhash(bytes, _image.width, _image.height); const [hash, average] = generateThumbhash(
bytes,
_image.width,
_image.height,
);
if (average) {
cache.set(
getCacheKey({
url,
suffix: "average",
}),
rgbToHex(average.r, average.g, average.b),
);
}
if (hash) { if (hash) {
const b64 = btoa(String.fromCharCode(...hash)); const b64 = btoa(String.fromCharCode(...hash));
@ -61,8 +88,6 @@ export function createThumbhash(
getCacheKey({ getCacheKey({
url, url,
suffix: "thumbnail", suffix: "thumbnail",
width: _image.width,
height: _image.height,
}), }),
b64, b64,
); );
@ -91,18 +116,26 @@ function verifyImage(
} }
export function getThumbhash({ url }: { url: string }) { export function getThumbhash({ url }: { url: string }) {
return cache.get<Uint8Array>( return Promise.all(
getCacheKey({ [
url, cache.get<Uint8Array>(
suffix: "thumbnail", getCacheKey({
width: 200, url,
height: 200, suffix: "thumbnail",
}), }),
),
cache.get<string>(
getCacheKey({
url,
suffix: "average",
}),
),
] as const,
); );
} }
export async function getImage({ url, width, height }: ImageCacheOptions) { export async function getImage(opts: ImageCacheOptions) {
const cacheKey = getCacheKey({ url, width, height }); const cacheKey = getCacheKey(opts);
const pointerCacheRaw = await cache.get<string>(cacheKey); const pointerCacheRaw = await cache.get<string>(cacheKey);
if (!pointerCacheRaw) return; if (!pointerCacheRaw) return;
@ -122,31 +155,29 @@ export async function getImage({ url, width, height }: ImageCacheOptions) {
export async function setImage( export async function setImage(
buffer: Uint8Array, buffer: Uint8Array,
{ url, width, height, mediaType }: ImageCacheOptions, opts: ImageCacheOptions,
) { ) {
const clone = new Uint8Array(buffer); const clone = new Uint8Array(buffer);
const imageCorrect = await verifyImage(clone); const imageCorrect = await verifyImage(clone);
if (!imageCorrect) { if (!imageCorrect) {
log.info("failed to store image", { url }); log.info("failed to store image", { url: opts.url });
return; return;
} }
const cacheKey = getCacheKey({ url, width, height }); const cacheKey = getCacheKey(opts);
const pointerId = await hash(cacheKey); const pointerId = await hash(cacheKey);
await cache.set(`image:${pointerId}`, clone); await cache.set(`image:${pointerId}`, clone, { expires: 60 * 60 * 24 });
cache.expire(pointerId, 60 * 60 * 24);
cache.expire(cacheKey, 60 * 60 * 24);
await cache.set( await cache.set(
cacheKey, cacheKey,
JSON.stringify({ JSON.stringify({
id: pointerId, id: pointerId,
url, ...("suffix" in opts
width, ? { suffix: opts.suffix }
height, : { width: opts.width, height: opts.height }),
mediaType,
}), }),
{ expires: 60 * 60 * 24 },
); );
} }

View File

@ -22,12 +22,13 @@ export async function addThumbnailToResource<T = Resource>(
): Promise<T> { ): Promise<T> {
const imageUrl = res?.meta?.image; const imageUrl = res?.meta?.image;
if (!imageUrl) return res; if (!imageUrl) return res;
const thumbhash = await getThumbhash({ url: imageUrl }); const [thumbhash, average] = await getThumbhash({ url: imageUrl });
if (!thumbhash) return res; if (!thumbhash) return res;
return { return {
...res, ...res,
meta: { meta: {
...res?.meta, ...res?.meta,
average: average,
thumbnail: thumbhash, thumbnail: thumbhash,
}, },
}; };

View File

@ -80,3 +80,26 @@ export class PromiseQueue {
return true; return true;
} }
} }
export class ConcurrentPromiseQueue {
/**
* Eingereihte Promises.
*/
private queues: PromiseQueue[] = [];
constructor(concurrency: number) {
this.queues = Array.from({ length: concurrency }).map(() => {
return new PromiseQueue();
});
}
private queueIndex = 0;
private getQueue() {
this.queueIndex = (this.queueIndex + 1) % this.queues.length;
return this.queues[this.queueIndex];
}
public enqueue<T = void>(promise: () => Promise<T>): Promise<T> {
return this.getQueue().enqueue(promise);
}
}

View File

@ -17,18 +17,13 @@ export type Article = {
date: Date; date: Date;
link: string; link: string;
thumbnail?: string; thumbnail?: string;
average?: string;
image?: string; image?: string;
author?: string; author?: string;
rating?: number; rating?: number;
}; };
}; };
const crud = createCrud<Article>({
prefix: "Media/articles/",
parse: parseArticle,
hasThumbnails: true,
});
function renderArticle(article: Article) { function renderArticle(article: Article) {
const meta = article.meta; const meta = article.meta;
if ("date" in meta) { if ("date" in meta) {
@ -100,9 +95,12 @@ function parseArticle(original: string, id: string): Article {
}; };
} }
const crud = createCrud<Article>({
prefix: "Media/articles/",
parse: parseArticle,
render: renderArticle,
hasThumbnails: true,
});
export const getAllArticles = crud.readAll; export const getAllArticles = crud.readAll;
export const getArticle = crud.read; export const getArticle = crud.read;
export const createArticle = (article: Article) => { export const createArticle = crud.create;
const content = renderArticle(article);
return crud.create(article.id, content);
};

View File

@ -14,6 +14,7 @@ export type Movie = {
date: Date; date: Date;
image: string; image: string;
thumbnail?: string; thumbnail?: string;
average?: string;
author: string; author: string;
rating: number; rating: number;
status: "not-seen" | "watch-again" | "finished"; status: "not-seen" | "watch-again" | "finished";
@ -99,12 +100,10 @@ export function parseMovie(original: string, id: string): Movie {
const crud = createCrud<Movie>({ const crud = createCrud<Movie>({
prefix: "Media/movies/", prefix: "Media/movies/",
parse: parseMovie, parse: parseMovie,
render: renderMovie,
hasThumbnails: true, hasThumbnails: true,
}); });
export const getMovie = crud.read; export const getMovie = crud.read;
export const getAllMovies = crud.readAll; export const getAllMovies = crud.readAll;
export const createMovie = (movie: Movie) => { export const createMovie = crud.create;
const content = renderMovie(movie);
return crud.create(movie.id, content);
};

View File

@ -37,6 +37,7 @@ export type Recipe = {
rating?: number; rating?: number;
portion?: number; portion?: number;
author?: string; author?: string;
average?: string;
thumbnail?: string; thumbnail?: string;
}; };
}; };

View File

@ -16,6 +16,7 @@ export type Series = {
image: string; image: string;
author: string; author: string;
rating: number; rating: number;
average?: string;
thumbnail?: string; thumbnail?: string;
status: "not-seen" | "watch-again" | "finished"; status: "not-seen" | "watch-again" | "finished";
}; };
@ -100,25 +101,10 @@ export function parseSeries(original: string, id: string): Series {
const crud = createCrud<Series>({ const crud = createCrud<Series>({
prefix: "Media/series/", prefix: "Media/series/",
parse: parseSeries, parse: parseSeries,
render: renderSeries,
hasThumbnails: true, hasThumbnails: true,
}); });
export const getSeries = (id: string) => export const getSeries = crud.read;
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 getAllSeries = crud.readAll;
export const createSeries = (series: Series) => { export const createSeries = crud.create;
const content = renderSeries(series);
return crud.create(series.id, content);
};

View File

@ -1,6 +1,6 @@
import { BadRequestError } from "@lib/errors.ts"; import { BadRequestError } from "@lib/errors.ts";
import { resources } from "@lib/resources.ts"; import { resources } from "@lib/resources.ts";
import { ResourceStatus } from "@lib/types.ts"; import { ResourceStatus, SearchResult } from "@lib/types.ts";
import { getTypeSenseClient } from "@lib/typesense.ts"; import { getTypeSenseClient } from "@lib/typesense.ts";
import { extractHashTags } from "@lib/string.ts"; import { extractHashTags } from "@lib/string.ts";
@ -11,6 +11,7 @@ type SearchParams = {
type?: ResourceType; type?: ResourceType;
tags?: string[]; tags?: string[];
status?: ResourceStatus; status?: ResourceStatus;
author?: string;
query_by?: string; query_by?: string;
}; };
@ -44,7 +45,7 @@ export function parseResourceUrl(_url: string): SearchParams | undefined {
export async function searchResource( export async function searchResource(
{ q, query_by = "name,description,author,tags", tags = [], type, status }: { q, query_by = "name,description,author,tags", tags = [], type, status }:
SearchParams, SearchParams,
) { ): Promise<SearchResult> {
const typesenseClient = await getTypeSenseClient(); const typesenseClient = await getTypeSenseClient();
if (!typesenseClient) { if (!typesenseClient) {
throw new Error("Query not available"); throw new Error("Query not available");

View File

@ -104,3 +104,15 @@ export const isLocalImage = (src: string) =>
export const isString = (input: string | undefined): input is string => { export const isString = (input: string | undefined): input is string => {
return typeof input === "string"; return typeof input === "string";
}; };
function componentToHex(c: number) {
if (c <= 1) {
c = Math.round(c * 255);
}
const hex = c.toString(16);
return hex.length == 1 ? "0" + hex : hex;
}
export function rgbToHex(r: number, g: number, b: number) {
return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
}

View File

@ -1,7 +1,8 @@
import * as thumbhash from "https://esm.sh/thumbhash@0.1.1"; import * as thumbhash from "https://esm.sh/thumbhash@0.1.1";
export function generateThumbhash(buffer: Uint8Array, w: number, h: number) { export function generateThumbhash(buffer: Uint8Array, w: number, h: number) {
return thumbhash.rgbaToThumbHash(w, h, buffer); const hash = thumbhash.rgbaToThumbHash(w, h, buffer);
return [hash, thumbhash.thumbHashToAverageRGBA(hash)] as const;
} }
export function generateDataURL(hash: string) { export function generateDataURL(hash: string) {

View File

@ -34,6 +34,18 @@ export interface TMDBSeries {
vote_count: number; vote_count: number;
} }
export type GenericResource = {
name: string;
id: string;
type: keyof typeof resources;
meta?: {
image?: string;
author?: string;
average?: string;
thumbnail?: string;
};
};
export interface GiteaOauthUser { export interface GiteaOauthUser {
sub: string; sub: string;
name: string; name: string;

View File

@ -37,6 +37,15 @@ export default function App({ Component }: AppProps) {
--background: rgb(43, 41, 48); --background: rgb(43, 41, 48);
--foreground: rgb(129, 129, 129); --foreground: rgb(129, 129, 129);
} }
.custom-grid {
grid-template-columns: repeat(auto-fit, minmax(37vw, 1fr)) ;
}
@media(min-width: 640px){
.custom-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)) ;
}
}
/* work-sans-regular - latin */ /* work-sans-regular - latin */
@font-face { @font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */

View File

@ -9,7 +9,7 @@ import {
import { parseMediaType } from "https://deno.land/std@0.175.0/media_types/parse_media_type.ts"; import { parseMediaType } from "https://deno.land/std@0.175.0/media_types/parse_media_type.ts";
import * as cache from "@lib/cache/image.ts"; import * as cache from "@lib/cache/image.ts";
import { SILVERBULLET_SERVER } from "@lib/env.ts"; import { SILVERBULLET_SERVER } from "@lib/env.ts";
import { PromiseQueue } from "@lib/promise.ts"; import { ConcurrentPromiseQueue, PromiseQueue } from "@lib/promise.ts";
import { BadRequestError } from "@lib/errors.ts"; import { BadRequestError } from "@lib/errors.ts";
import { createLogger } from "@lib/log.ts"; import { createLogger } from "@lib/log.ts";
@ -109,7 +109,7 @@ function parseParams(reqUrl: URL) {
}; };
} }
const queue = new PromiseQueue(); const queue = new ConcurrentPromiseQueue(2);
async function processImage(imageUrl: string, params: ImageParams) { async function processImage(imageUrl: string, params: ImageParams) {
const remoteImage = await getRemoteImage(imageUrl); const remoteImage = await getRemoteImage(imageUrl);
@ -165,20 +165,21 @@ const GET = async (
processImage(imageUrl, params) processImage(imageUrl, params)
); );
const clonedImage = resizedImage.slice();
setTimeout(() => { setTimeout(() => {
cache.setImage(resizedImage.slice(), { cache.setImage(clonedImage, {
url: imageUrl, url: imageUrl,
width: params.width, width: params.width,
height: params.height, height: params.height,
mediaType: mediaType, mediaType: mediaType,
}); });
}, 10); }, 50);
log.debug("not-cached", { imageUrl }); log.debug("not-cached", { imageUrl });
cache.getThumbhash({ url: imageUrl }).then((hash) => { cache.getThumbhash({ url: imageUrl }).then(([hash]) => {
if (!hash) { if (!hash) {
cache.createThumbhash(resizedImage.slice(), imageUrl).catch((_err) => { cache.createThumbhash(clonedImage.slice(), imageUrl).catch((_err) => {
// //
}); });
} }

View File

@ -8,8 +8,11 @@ import { IconArrowLeft } from "@components/icons.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx"; import { RedirectSearchHandler } from "@islands/Search.tsx";
import { parseResourceUrl, searchResource } from "@lib/search.ts"; import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { SearchResult } from "@lib/types.ts"; import { SearchResult } from "@lib/types.ts";
import { ResourceCard } from "@components/Card.tsx";
export const handler: Handlers<Article[] | null> = { export const handler: Handlers<
{ articles: Article[] | null; searchResults?: SearchResult }
> = {
async GET(req, ctx) { async GET(req, ctx) {
const articles = await getAllArticles(); const articles = await getAllArticles();
const searchParams = parseResourceUrl(req.url); const searchParams = parseResourceUrl(req.url);
@ -44,16 +47,12 @@ export default function Greet(
<RedirectSearchHandler /> <RedirectSearchHandler />
<KMenu type="main" context={{ type: "article" }} /> <KMenu type="main" context={{ type: "article" }} />
<Grid> <Grid>
{articles?.map((doc) => { {articles?.map((doc) => (
return ( <ResourceCard
<Card sublink="articles"
image={doc?.meta?.image || "/placeholder.svg"} res={doc}
thumbnail={doc?.meta?.thumbnail} />
link={`/articles/${doc.id}`} ))}
title={doc.name}
/>
);
})}
</Grid> </Grid>
</MainLayout> </MainLayout>
); );

View File

@ -15,8 +15,9 @@ export default function Home(props: PageProps) {
<RedirectSearchHandler /> <RedirectSearchHandler />
<KMenu type="main" context={false} /> <KMenu type="main" context={false} />
<MainLayout url={props.url}> <MainLayout url={props.url}>
<div class="flex flex-wrap items-center gap-4 px-4"> <h1 class="text-4xl mb-4 mt-3 text-white">Resources</h1>
{Object.values(resources).map((m) => { <div class="flex flex-wrap items-center gap-4">
{Object.values(resources).filter((v) => v.link !== "/").map((m) => {
return ( return (
<Card <Card
title={`${m.name}`} title={`${m.name}`}

View File

@ -29,7 +29,13 @@ export default function Greet(
<KMenu type="main" context={movie} /> <KMenu type="main" context={movie} />
<RecipeHero <RecipeHero
data={movie} data={movie}
subline={[author, date.toString()]} subline={[
author && {
title: author,
href: `/movies?q=${encodeURIComponent(author)}`,
},
date.toString(),
]}
editLink={session editLink={session
? `https://notes.max-richter.dev/Media/movies/${movie.id}` ? `https://notes.max-richter.dev/Media/movies/${movie.id}`
: ""} : ""}

View File

@ -1,7 +1,7 @@
import { Handlers, PageProps } from "$fresh/server.ts"; import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx"; import { MainLayout } from "@components/layouts/main.tsx";
import { getAllMovies, Movie } from "@lib/resource/movies.ts"; import { getAllMovies, Movie } from "@lib/resource/movies.ts";
import { MovieCard } from "@components/MovieCard.tsx"; import { ResourceCard } from "@components/Card.tsx";
import { Grid } from "@components/Grid.tsx"; import { Grid } from "@components/Grid.tsx";
import { IconArrowLeft } from "@components/icons.tsx"; import { IconArrowLeft } from "@components/icons.tsx";
import { KMenu } from "@islands/KMenu.tsx"; import { KMenu } from "@islands/KMenu.tsx";
@ -9,7 +9,9 @@ import { RedirectSearchHandler } from "@islands/Search.tsx";
import { parseResourceUrl, searchResource } from "@lib/search.ts"; import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { SearchResult } from "@lib/types.ts"; import { SearchResult } from "@lib/types.ts";
export const handler: Handlers<Movie[] | null> = { export const handler: Handlers<
{ movies: Movie[] | null; searchResults?: SearchResult }
> = {
async GET(req, ctx) { async GET(req, ctx) {
const movies = await getAllMovies(); const movies = await getAllMovies();
const searchParams = parseResourceUrl(req.url); const searchParams = parseResourceUrl(req.url);
@ -46,7 +48,7 @@ export default function Greet(
</header> </header>
<Grid> <Grid>
{movies?.map((doc) => { {movies?.map((doc) => {
return <MovieCard movie={doc} />; return <ResourceCard res={doc} />;
})} })}
</Grid> </Grid>
</MainLayout> </MainLayout>

View File

@ -1,5 +1,4 @@
import { Handlers, PageProps } from "$fresh/server.ts"; import { Handlers, PageProps } from "$fresh/server.ts";
import { RecipeCard } from "@components/RecipeCard.tsx";
import { MainLayout } from "@components/layouts/main.tsx"; import { MainLayout } from "@components/layouts/main.tsx";
import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts"; import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts";
import { Grid } from "@components/Grid.tsx"; import { Grid } from "@components/Grid.tsx";
@ -8,8 +7,11 @@ import { KMenu } from "@islands/KMenu.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx"; import { RedirectSearchHandler } from "@islands/Search.tsx";
import { parseResourceUrl, searchResource } from "@lib/search.ts"; import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { SearchResult } from "@lib/types.ts"; import { SearchResult } from "@lib/types.ts";
import { ResourceCard } from "@components/Card.tsx";
export const handler: Handlers<Recipe[] | null> = { export const handler: Handlers<
{ recipes: Recipe[] | null; searchResults?: SearchResult }
> = {
async GET(req, ctx) { async GET(req, ctx) {
const recipes = await getAllRecipes(); const recipes = await getAllRecipes();
const searchParams = parseResourceUrl(req.url); const searchParams = parseResourceUrl(req.url);
@ -45,7 +47,7 @@ export default function Greet(
</header> </header>
<Grid> <Grid>
{recipes?.map((doc) => { {recipes?.map((doc) => {
return <RecipeCard recipe={doc} />; return <ResourceCard sublink="recipes" res={doc} />;
})} })}
</Grid> </Grid>
</MainLayout> </MainLayout>

View File

@ -7,7 +7,7 @@ import { getSeries, Series } from "@lib/resource/series.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx"; import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx"; import { KMenu } from "@islands/KMenu.tsx";
export const handler: Handlers<Series | null> = { export const handler: Handlers<{ serie: Series; session: unknown }> = {
async GET(_, ctx) { async GET(_, ctx) {
const serie = await getSeries(ctx.params.name); const serie = await getSeries(ctx.params.name);
return ctx.render({ serie, session: ctx.state.session }); return ctx.render({ serie, session: ctx.state.session });

View File

@ -5,11 +5,13 @@ import { IconArrowLeft } from "@components/icons.tsx";
import { getAllSeries, Series } from "@lib/resource/series.ts"; import { getAllSeries, Series } from "@lib/resource/series.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx"; import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx"; import { KMenu } from "@islands/KMenu.tsx";
import { MovieCard } from "@components/MovieCard.tsx"; import { ResourceCard } from "@components/Card.tsx";
import { parseResourceUrl, searchResource } from "@lib/search.ts"; import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { SearchResult } from "@lib/types.ts"; import { SearchResult } from "@lib/types.ts";
export const handler: Handlers<Series[] | null> = { export const handler: Handlers<
{ series: Series[] | null; searchResults?: SearchResult }
> = {
async GET(req, ctx) { async GET(req, ctx) {
const series = await getAllSeries(); const series = await getAllSeries();
const searchParams = parseResourceUrl(req.url); const searchParams = parseResourceUrl(req.url);
@ -46,7 +48,7 @@ export default function Greet(
</header> </header>
<Grid> <Grid>
{series?.map((doc) => { {series?.map((doc) => {
return <MovieCard sublink="series" movie={doc} />; return <ResourceCard sublink="series" res={doc} />;
})} })}
</Grid> </Grid>
</MainLayout> </MainLayout>

View File

@ -1,13 +1,4 @@
.custom-grid {
grid-template-columns: repeat(auto-fit, minmax(37vw, 1fr)) ;
}
@media(min-width: 640px){
.custom-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)) ;
}
}
.animate-hover { .animate-hover {
animation: hover 4s infinite; animation: hover 4s infinite;
@ -31,6 +22,18 @@ input::-webkit-inner-spin-button {
/* Firefox */ /* Firefox */
input[type=number] { input[type=number] {
-moz-appearance: textfield; -moz-appearance: textfield;
appearance: textfield;
}
.fix-border::before {
content: "";
width: calc(100% + 2px);
height: calc(100% + 2px);
position: absolute;
top: 0px;
left: 0px;
border: solid thin #141218;
border-radius: 1.5rem;
} }
.noisy-gradient::after { .noisy-gradient::after {

View File

@ -248,12 +248,16 @@ document.querySelectorAll("[data-thumb]").forEach((entry) => {
const child = entry.querySelector("img[data-thumb-img]"); const child = entry.querySelector("img[data-thumb-img]");
setTimeout(() => { setTimeout(() => {
const isLoaded = child && child.complete && child.naturalHeight !== 0; const isLoaded = child && child.complete && child.naturalHeight !== 0;
console.log(isLoaded, child.getAttribute("src"));
if (child && !isLoaded) { if (child && !isLoaded) {
child.style.opacity = 0; child.style.opacity = 0;
child.style.filter = "blur(5px)";
child.addEventListener("load", () => { child.addEventListener("load", () => {
child.style.transition = "opacity 0.3s ease"; child.style.transition = "opacity 0.3s ease, filter 0.6s ease";
child.style.opacity = 1; child.style.opacity = 1;
child.style.filter = "blur(0px)";
setTimeout(() => {
entry.style.background = "";
}, 400);
}); });
} }
}, 50); }, 50);