feat: add image resizing

This commit is contained in:
max_richter 2023-07-26 15:48:03 +02:00
parent 3cc5a94a18
commit 764b434e0a
6 changed files with 206 additions and 62 deletions

View File

@ -1,33 +1,71 @@
import type { Ingredient, Ingredients } from "../lib/recipes.ts"; import type {
Ingredient,
IngredientGroup,
Ingredients,
} from "../lib/recipes.ts";
import { FunctionalComponent } from "preact";
type IngredientsProps = { type IngredientsProps = {
ingredients: Ingredients; ingredients: Ingredients;
}; };
function formatIngredient(ingredient: Ingredient) { const IngredientList = ({ ingredients }: { ingredients: Ingredient[] }) => {
return `${
ingredient.amount && ingredient.unit &&
` - ${ingredient.amount} ${ingredient.unit}`
} ${ingredient.type}`;
}
export const IngredientsList = ({ ingredients }: IngredientsProps) => {
return ( return (
<div> <>
{ingredients.map((item, index) => ( {ingredients.map((item, index) => {
<div key={index} class="mb-4"> // Render Ingredient
{"type" in item && formatIngredient(item)} const { type, amount, unit } = item as Ingredient;
{"ingredients" in item && Array.isArray(item.ingredients) && ( return (
<ul class="pl-4 list-disc"> <tr key={index}>
{item.ingredients.map((ingredient, idx) => ( <td class="pr-4 py-2">
<li key={idx}> {amount + (typeof unit !== "undefined" ? unit : "")}
{formatIngredient(ingredient)} </td>
</li> <td class="px-4 py-2">{type}</td>
))} </tr>
</ul> );
)} })}
</div> </>
))}
</div>
); );
}; };
const IngredientTable: FunctionalComponent<{ ingredients: Ingredients }> = (
{ ingredients },
) => {
return (
<table class="w-full border-collapse table-auto">
<tbody>
{ingredients.map((item, index) => {
if ("name" in item) {
// Render IngredientGroup
const { name, ingredients: groupIngredients } =
item as IngredientGroup;
return (
<>
<tr key={index}>
<td colSpan={3} class="pr-4 py-2 font-italic">{name}</td>
</tr>
<IngredientList ingredients={groupIngredients} />
</>
);
} else {
// Render Ingredient
const { type, amount, unit } = item as Ingredient;
return (
<tr key={index}>
<td class="pr-4 py-2">
{(amount ? amount : "") +
(unit ? (" " + unit) : "")}
</td>
<td class="px-4 py-2">{type}</td>
</tr>
);
}
})}
</tbody>
</table>
);
};
export const IngredientsList = ({ ingredients }: IngredientsProps) => {
return <IngredientTable ingredients={ingredients} />;
};

View File

@ -1,4 +1,3 @@
import { Document } from "../lib/documents.ts";
import { Recipe } from "../lib/recipes.ts"; import { Recipe } from "../lib/recipes.ts";
export function RecipeCard({ recipe }: { recipe: Recipe }) { export function RecipeCard({ recipe }: { recipe: Recipe }) {
@ -6,20 +5,25 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
<a <a
href={`/recipes/${recipe.id}`} href={`/recipes/${recipe.id}`}
style={{ style={{
backgroundImage: `url(${recipe?.meta?.image})`, backgroundImage: `url(${
recipe?.meta?.image || "/api/recipes/images/placeholder.jpg"
}?width=200&height=200)`,
backgroundSize: "cover", backgroundSize: "cover",
}} }}
class="bg-gray-900 text-white rounded-3xl shadow-md p-4 class="bg-gray-900 text-white rounded-3xl shadow-md p-4 relative overflow-hidden
flex flex-col justify-between lg:w-56 lg:h-56
lg:w-56 lg:h-56 sm:w-40 sm:h-40
sm:w-40 sm:h-40 w-32 h-32"
w-32 h-32"
> >
<div> <div class="h-full flex flex-col justify-between relative z-10">
</div> <div>
<div class="mt-2 "> {/* Recipe Card content */}
{recipe.name} </div>
<div class="mt-2">
{recipe.name}
</div>
</div> </div>
<div class="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-black to-transparent" />
</a> </a>
); );
} }

View File

@ -4,14 +4,14 @@ export function RecipeHero({ recipe }: { recipe: Recipe }) {
return ( return (
<div class="relative w-full h-[400px] rounded-3xl overflow-hidden bg-black"> <div class="relative w-full h-[400px] rounded-3xl overflow-hidden bg-black">
<img <img
src={recipe?.meta?.image} src={recipe?.meta?.image + "?width=800"}
alt="Recipe Banner" alt="Recipe Banner"
class="object-cover w-full h-full" class="object-cover w-full h-full"
/> />
<div class="absolute top-4 left-4"> <div class="absolute top-4 left-4 pt-4">
<a <a
class="px-4 py-2 bg-gray-300 text-gray-800 rounded-lg" class="px-4 ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg"
href="/recipes" href="/recipes"
> >
Back Back
@ -19,8 +19,8 @@ export function RecipeHero({ recipe }: { recipe: Recipe }) {
</div> </div>
<div <div
class="absolute inset-x-0 bottom-0 py-4 px-12 py-8" class="absolute inset-x-0 bottom-0 py-4 px-8 py-8"
style={{ background: "linear-gradient(0deg, #fffe, #fff0)" }} style={{ background: "linear-gradient(#0000, #fff9)" }}
> >
<h2 class="text-4xl font-bold mt-4" style={{ color: "#1F1F1F" }}> <h2 class="text-4xl font-bold mt-4" style={{ color: "#1F1F1F" }}>
{recipe.name} {recipe.name}

View File

@ -47,12 +47,12 @@ function parseIngredientItem(listItem: DocumentChild): Ingredient | undefined {
tableSpoon: { tableSpoon: {
short: "EL", short: "EL",
plural: "Table Spoons", plural: "Table Spoons",
alternates: ["el", "EL"], alternates: ["el", "EL", "Tbsp", "tbsp"],
}, },
teaSpoon: { teaSpoon: {
short: "TL", short: "TL",
plural: "Tea Spoon", plural: "Tea Spoon",
alternates: ["tl", "TL"], alternates: ["tl", "TL", "Tsp", "tsp"],
}, },
litre: { litre: {
short: "L", short: "L",

View File

@ -1,26 +1,128 @@
import { HandlerContext } from "$fresh/server.ts"; import { HandlerContext } from "$fresh/server.ts";
import {
ImageMagick,
initializeImageMagick,
MagickGeometry,
} from "https://deno.land/x/imagemagick_deno@0.0.14/mod.ts";
import { parseMediaType } from "https://deno.land/std@0.175.0/media_types/parse_media_type.ts";
function copyHeader(headerName: string, to: Headers, from: Headers) { await initializeImageMagick();
const hdrVal = from.get(headerName);
if (hdrVal) { const cache = new Map<string, Promise<Response>>();
to.set(headerName, hdrVal);
async function getRemoteImage(image: string) {
const sourceRes = await fetch(image);
if (!sourceRes.ok) {
return "Error retrieving image from URL.";
} }
const mediaType = parseMediaType(sourceRes.headers.get("Content-Type")!)[0];
if (mediaType.split("/")[0] !== "image") {
return "URL is not image type.";
}
return {
buffer: new Uint8Array(await sourceRes.arrayBuffer()),
mediaType,
};
}
function getWidthHeight(
current: { width: number; height: number },
final: { width: number; height: number },
) {
const ratio = (current.width / final.width) > (current.height / final.height)
? (current.height / final.height)
: (current.width / final.width);
return new MagickGeometry(
current.width / ratio,
current.height / ratio,
);
}
function modifyImage(
imageBuffer: Uint8Array,
params: { width: number; height: number; mode: "resize" | "crop" },
) {
return new Promise<Uint8Array>((resolve) => {
ImageMagick.read(imageBuffer, (image) => {
const sizingData = getWidthHeight(image, params);
if (params.mode === "resize") {
image.resize(sizingData);
} else {
image.crop(sizingData);
}
image.write((data) => resolve(data));
});
});
}
function parseParams(reqUrl: URL) {
const height = Number(reqUrl.searchParams.get("height")) || 0;
const width = Number(reqUrl.searchParams.get("width")) || 0;
if (height === 0 && width === 0) {
//return "Missing non-zero 'height' or 'width' query parameter.";
}
if (height < 0 || width < 0) {
return "Negative height or width is not supported.";
}
const maxDimension = 2048;
if (height > maxDimension || width > maxDimension) {
return `Width and height cannot exceed ${maxDimension}.`;
}
return {
height,
width,
};
}
async function getImageResponse(
imageUrl: string,
remoteImage: { buffer: Uint8Array; mediaType: string },
params: { width: number; height: number },
): Promise<Response> {
const modifiedImage = await modifyImage(remoteImage.buffer, {
...params,
mode: "resize",
});
const response = new Response(modifiedImage, {
headers: {
"Content-Type": remoteImage.mediaType,
},
});
return response;
} }
export const handler = async ( export const handler = async (
_req: Request, _req: Request,
_ctx: HandlerContext, _ctx: HandlerContext,
): Promise<Response> => { ): Promise<Response> => {
const proxyRes = await fetch( const imageUrl = "http://192.168.178.56:3007/Recipes/images/" +
"http://192.168.178.56:3007/Recipes/images/" + _ctx.params.image, _ctx.params.image;
);
console.log({ params: _ctx.params }); const url = new URL(_req.url);
const headers = new Headers();
copyHeader("content-length", headers, proxyRes.headers); const params = parseParams(url);
copyHeader("content-type", headers, proxyRes.headers);
copyHeader("content-disposition", headers, proxyRes.headers); if (typeof params === "string") {
return new Response(proxyRes.body, { return new Response(params, { status: 400 });
status: proxyRes.status, }
headers,
}); const remoteImage = await getRemoteImage(imageUrl);
if (typeof remoteImage === "string") {
return new Response(remoteImage, { status: 400 });
}
const imageId = `${imageUrl}.${params.width}.${params.height}`;
if (cache.has(imageId)) {
return (await cache.get(imageId)!).clone();
}
const response = getImageResponse(imageUrl, remoteImage, params);
cache.set(imageId, response);
return response;
}; };

View File

@ -16,10 +16,10 @@ export default function Greet(props: PageProps<Recipe>) {
return ( return (
<MainLayout> <MainLayout>
<RecipeHero recipe={props.data} /> <RecipeHero recipe={props.data} />
<div class="px-12 text-white mt-10"> <div class="px-8 text-white mt-10">
<h3 class="text-3xl">Ingredients</h3> <h3 class="text-3xl my-5">Ingredients</h3>
<IngredientsList ingredients={props.data.ingredients} /> <IngredientsList ingredients={props.data.ingredients} />
<h3 class="text-3xl">Preperation</h3> <h3 class="text-3xl my-5">Preparation</h3>
</div> </div>
</MainLayout> </MainLayout>
); );