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 { GenericResource } from "@lib/types.ts";
export function Card(
{ link, title, image, thumbnail, backgroundSize = 100 }: {
{ link, title, image, thumbnail, backgroundColor, backgroundSize = 100 }: {
backgroundSize?: number;
backgroundColor?: string;
thumbnail?: string;
link?: string;
title?: string;
@ -12,7 +14,7 @@ export function Card(
) {
const backgroundStyle = {
backgroundSize: "cover",
boxShadow: "0px -60px 90px black inset, 0px 10px 20px #fff1 inset",
backgroundColor: backgroundColor,
};
if (backgroundSize !== 100) {
@ -26,18 +28,20 @@ export function Card(
href={link}
style={backgroundStyle}
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
sm:w-48 sm:h-48
w-[37vw] h-[37vw]"
>
{true && (
<img
class="absolute rounded-3xl top-0 left-0 object-cover w-full h-full"
data-thumb-img
loading="lazy"
src={image || "/placeholder.svg"}
/>
<span class="absolute top-0 left-0 overflow-hidden rounded-3xl w-full h-full">
<img
class="w-full h-full object-cover"
data-thumb-img
loading="lazy"
src={image || "/placeholder.svg"}
/>
</span>
)}
<div
class="p-4 flex flex-col justify-between relative z-10"
@ -48,7 +52,7 @@ export function Card(
width: "calc(100% + 0.5px)",
marginTop: "-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>
@ -63,3 +67,23 @@ export function Card(
</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;
alt?: string;
thumbnail?: string;
fill?: boolean;
width?: number | string;
height?: number | string;
style?: CSS.HtmlAttributes;
@ -48,10 +49,10 @@ const Image = (
return (
<span
style={{
position: "absolute",
width: "100%",
height: "100%",
zIndex: -1,
position: props.fill ? "absolute" : "",
width: props.fill ? "100%" : "",
height: props.fill ? "100%" : "",
zIndex: props.fill ? -1 : "",
}}
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,
} from "@components/icons.tsx";
import Image from "@components/Image.tsx";
import { GenericResource } from "@lib/types.ts";
export function RecipeHero(
{ data, subline, backlink, editLink }: {
backlink: string;
subline?: string[];
subline?: (string | { title: string; href: string })[];
editLink?: string;
data: {
meta?: { thumbnail?: string; image?: string; link?: string };
name: string;
};
data: GenericResource;
},
) {
const { meta: { image } = {} } = data;
console.log({ meta: data.meta });
return (
<div
class={`flex justify-between flex-col relative w-full min-h-[${
@ -28,6 +28,7 @@ export function RecipeHero(
{image &&
(
<Image
fill
src={image}
thumbnail={data.meta?.thumbnail}
alt="Recipe Banner"
@ -90,8 +91,14 @@ export function RecipeHero(
class={`relative z-50 flex gap-5 font-sm text-light mt-3`}
style={{ color: image ? "#1F1F1F" : "white" }}
>
{subline.filter((s) => s && s?.length > 1).map((s) => {
return <span>{s}</span>;
{subline.filter((s) =>
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>
)}

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

View File

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

View File

@ -80,3 +80,26 @@ export class PromiseQueue {
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;
link: string;
thumbnail?: string;
average?: string;
image?: string;
author?: string;
rating?: number;
};
};
const crud = createCrud<Article>({
prefix: "Media/articles/",
parse: parseArticle,
hasThumbnails: true,
});
function renderArticle(article: Article) {
const meta = article.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 getArticle = crud.read;
export const createArticle = (article: Article) => {
const content = renderArticle(article);
return crud.create(article.id, content);
};
export const createArticle = crud.create;

View File

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

View File

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

View File

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

View File

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

View File

@ -34,6 +34,18 @@ export interface TMDBSeries {
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 {
sub: string;
name: string;

View File

@ -37,6 +37,15 @@ export default function App({ Component }: AppProps) {
--background: rgb(43, 41, 48);
--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 */
@font-face {
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 * as cache from "@lib/cache/image.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 { 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) {
const remoteImage = await getRemoteImage(imageUrl);
@ -165,20 +165,21 @@ const GET = async (
processImage(imageUrl, params)
);
const clonedImage = resizedImage.slice();
setTimeout(() => {
cache.setImage(resizedImage.slice(), {
cache.setImage(clonedImage, {
url: imageUrl,
width: params.width,
height: params.height,
mediaType: mediaType,
});
}, 10);
}, 50);
log.debug("not-cached", { imageUrl });
cache.getThumbhash({ url: imageUrl }).then((hash) => {
cache.getThumbhash({ url: imageUrl }).then(([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 { parseResourceUrl, searchResource } from "@lib/search.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) {
const articles = await getAllArticles();
const searchParams = parseResourceUrl(req.url);
@ -44,16 +47,12 @@ export default function Greet(
<RedirectSearchHandler />
<KMenu type="main" context={{ type: "article" }} />
<Grid>
{articles?.map((doc) => {
return (
<Card
image={doc?.meta?.image || "/placeholder.svg"}
thumbnail={doc?.meta?.thumbnail}
link={`/articles/${doc.id}`}
title={doc.name}
/>
);
})}
{articles?.map((doc) => (
<ResourceCard
sublink="articles"
res={doc}
/>
))}
</Grid>
</MainLayout>
);

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { getSeries, Series } from "@lib/resource/series.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { KMenu } from "@islands/KMenu.tsx";
export const handler: Handlers<Series | null> = {
export const handler: Handlers<{ serie: Series; session: unknown }> = {
async GET(_, ctx) {
const serie = await getSeries(ctx.params.name);
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 { RedirectSearchHandler } from "@islands/Search.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 { SearchResult } from "@lib/types.ts";
export const handler: Handlers<Series[] | null> = {
export const handler: Handlers<
{ series: Series[] | null; searchResults?: SearchResult }
> = {
async GET(req, ctx) {
const series = await getAllSeries();
const searchParams = parseResourceUrl(req.url);
@ -46,7 +48,7 @@ export default function Greet(
</header>
<Grid>
{series?.map((doc) => {
return <MovieCard sublink="series" movie={doc} />;
return <ResourceCard sublink="series" res={doc} />;
})}
</Grid>
</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 {
animation: hover 4s infinite;
@ -31,6 +22,18 @@ input::-webkit-inner-spin-button {
/* Firefox */
input[type=number] {
-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 {

View File

@ -248,12 +248,16 @@ document.querySelectorAll("[data-thumb]").forEach((entry) => {
const child = entry.querySelector("img[data-thumb-img]");
setTimeout(() => {
const isLoaded = child && child.complete && child.naturalHeight !== 0;
console.log(isLoaded, child.getAttribute("src"));
if (child && !isLoaded) {
child.style.opacity = 0;
child.style.filter = "blur(5px)";
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.filter = "blur(0px)";
setTimeout(() => {
entry.style.background = "";
}, 400);
});
}
}, 50);