feat: make author clickable
This commit is contained in:
parent
d2a02fcf34
commit
2b4173d759
@ -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}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -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}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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
95
lib/cache/image.ts
vendored
@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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);
|
|
||||||
};
|
|
||||||
|
@ -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);
|
|
||||||
};
|
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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);
|
|
||||||
};
|
|
||||||
|
@ -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");
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
@ -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) {
|
||||||
|
12
lib/types.ts
12
lib/types.ts
@ -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;
|
||||||
|
@ -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. */
|
||||||
|
@ -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) => {
|
||||||
//
|
//
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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}`}
|
||||||
|
@ -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}`
|
||||||
: ""}
|
: ""}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 });
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user