feat: add image resizing
This commit is contained in:
parent
3cc5a94a18
commit
764b434e0a
@ -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 = {
|
||||
ingredients: Ingredients;
|
||||
};
|
||||
|
||||
function formatIngredient(ingredient: Ingredient) {
|
||||
return `${
|
||||
ingredient.amount && ingredient.unit &&
|
||||
` - ${ingredient.amount} ${ingredient.unit}`
|
||||
} ${ingredient.type}`;
|
||||
}
|
||||
|
||||
export const IngredientsList = ({ ingredients }: IngredientsProps) => {
|
||||
const IngredientList = ({ ingredients }: { ingredients: Ingredient[] }) => {
|
||||
return (
|
||||
<div>
|
||||
{ingredients.map((item, index) => (
|
||||
<div key={index} class="mb-4">
|
||||
{"type" in item && formatIngredient(item)}
|
||||
{"ingredients" in item && Array.isArray(item.ingredients) && (
|
||||
<ul class="pl-4 list-disc">
|
||||
{item.ingredients.map((ingredient, idx) => (
|
||||
<li key={idx}>
|
||||
{formatIngredient(ingredient)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{ingredients.map((item, index) => {
|
||||
// Render Ingredient
|
||||
const { type, amount, unit } = item as Ingredient;
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td class="pr-4 py-2">
|
||||
{amount + (typeof unit !== "undefined" ? unit : "")}
|
||||
</td>
|
||||
<td class="px-4 py-2">{type}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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} />;
|
||||
};
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { Document } from "../lib/documents.ts";
|
||||
import { Recipe } from "../lib/recipes.ts";
|
||||
|
||||
export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
@ -6,20 +5,25 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
||||
<a
|
||||
href={`/recipes/${recipe.id}`}
|
||||
style={{
|
||||
backgroundImage: `url(${recipe?.meta?.image})`,
|
||||
backgroundImage: `url(${
|
||||
recipe?.meta?.image || "/api/recipes/images/placeholder.jpg"
|
||||
}?width=200&height=200)`,
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
class="bg-gray-900 text-white rounded-3xl shadow-md p-4
|
||||
flex flex-col justify-between
|
||||
lg:w-56 lg:h-56
|
||||
sm:w-40 sm:h-40
|
||||
w-32 h-32"
|
||||
class="bg-gray-900 text-white rounded-3xl shadow-md p-4 relative overflow-hidden
|
||||
lg:w-56 lg:h-56
|
||||
sm:w-40 sm:h-40
|
||||
w-32 h-32"
|
||||
>
|
||||
<div>
|
||||
</div>
|
||||
<div class="mt-2 ">
|
||||
{recipe.name}
|
||||
<div class="h-full flex flex-col justify-between relative z-10">
|
||||
<div>
|
||||
{/* Recipe Card content */}
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{recipe.name}
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-black to-transparent" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@ -4,14 +4,14 @@ export function RecipeHero({ recipe }: { recipe: Recipe }) {
|
||||
return (
|
||||
<div class="relative w-full h-[400px] rounded-3xl overflow-hidden bg-black">
|
||||
<img
|
||||
src={recipe?.meta?.image}
|
||||
src={recipe?.meta?.image + "?width=800"}
|
||||
alt="Recipe Banner"
|
||||
class="object-cover w-full h-full"
|
||||
/>
|
||||
|
||||
<div class="absolute top-4 left-4">
|
||||
<div class="absolute top-4 left-4 pt-4">
|
||||
<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"
|
||||
>
|
||||
Back
|
||||
@ -19,8 +19,8 @@ export function RecipeHero({ recipe }: { recipe: Recipe }) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 py-4 px-12 py-8"
|
||||
style={{ background: "linear-gradient(0deg, #fffe, #fff0)" }}
|
||||
class="absolute inset-x-0 bottom-0 py-4 px-8 py-8"
|
||||
style={{ background: "linear-gradient(#0000, #fff9)" }}
|
||||
>
|
||||
<h2 class="text-4xl font-bold mt-4" style={{ color: "#1F1F1F" }}>
|
||||
{recipe.name}
|
||||
|
@ -47,12 +47,12 @@ function parseIngredientItem(listItem: DocumentChild): Ingredient | undefined {
|
||||
tableSpoon: {
|
||||
short: "EL",
|
||||
plural: "Table Spoons",
|
||||
alternates: ["el", "EL"],
|
||||
alternates: ["el", "EL", "Tbsp", "tbsp"],
|
||||
},
|
||||
teaSpoon: {
|
||||
short: "TL",
|
||||
plural: "Tea Spoon",
|
||||
alternates: ["tl", "TL"],
|
||||
alternates: ["tl", "TL", "Tsp", "tsp"],
|
||||
},
|
||||
litre: {
|
||||
short: "L",
|
||||
|
@ -1,26 +1,128 @@
|
||||
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) {
|
||||
const hdrVal = from.get(headerName);
|
||||
if (hdrVal) {
|
||||
to.set(headerName, hdrVal);
|
||||
await initializeImageMagick();
|
||||
|
||||
const cache = new Map<string, Promise<Response>>();
|
||||
|
||||
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 (
|
||||
_req: Request,
|
||||
_ctx: HandlerContext,
|
||||
): Promise<Response> => {
|
||||
const proxyRes = await fetch(
|
||||
"http://192.168.178.56:3007/Recipes/images/" + _ctx.params.image,
|
||||
);
|
||||
console.log({ params: _ctx.params });
|
||||
const headers = new Headers();
|
||||
copyHeader("content-length", headers, proxyRes.headers);
|
||||
copyHeader("content-type", headers, proxyRes.headers);
|
||||
copyHeader("content-disposition", headers, proxyRes.headers);
|
||||
return new Response(proxyRes.body, {
|
||||
status: proxyRes.status,
|
||||
headers,
|
||||
});
|
||||
const imageUrl = "http://192.168.178.56:3007/Recipes/images/" +
|
||||
_ctx.params.image;
|
||||
|
||||
const url = new URL(_req.url);
|
||||
|
||||
const params = parseParams(url);
|
||||
|
||||
if (typeof params === "string") {
|
||||
return new Response(params, { status: 400 });
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
@ -16,10 +16,10 @@ export default function Greet(props: PageProps<Recipe>) {
|
||||
return (
|
||||
<MainLayout>
|
||||
<RecipeHero recipe={props.data} />
|
||||
<div class="px-12 text-white mt-10">
|
||||
<h3 class="text-3xl">Ingredients</h3>
|
||||
<div class="px-8 text-white mt-10">
|
||||
<h3 class="text-3xl my-5">Ingredients</h3>
|
||||
<IngredientsList ingredients={props.data.ingredients} />
|
||||
<h3 class="text-3xl">Preperation</h3>
|
||||
<h3 class="text-3xl my-5">Preparation</h3>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user