feat(recipes): client-side image editor + modernize /add to match /edit
CI / update (push) Has been cancelled

Add a from-scratch photo editor (crop, max-resolution scale-to-fit,
WebP quality with live final-size + dimensions readout) that opens on
image pick in the recipe add/edit flow. Conversion uses the browser's
canvas WebP encoder (sharp can't run client-side); crop, scale and the
size readout are built by hand.

Server now stores the client WebP full image byte-for-byte (passthrough)
so the on-disk file matches the user's chosen quality/size; sharp still
derives the 800px thumb and OKLAB colour. Non-WebP uploads keep the old
q90 re-encode fallback.

Rework /add to reuse EditTitleImgParallax (parallax hero +
titleExtras/below-hero layout, shape-tile Backform, SaveFab + optional
translation), replacing the antiquated CardAdd card. Move the edit/remove
image controls into the hero, below the fixed header. Delete now-dead
CardAdd and RecipeEditor.
This commit is contained in:
2026-05-30 15:54:28 +02:00
parent fb54f6907f
commit cd7912fa8f
9 changed files with 1478 additions and 782 deletions
-2
View File
@@ -173,7 +173,6 @@ Generated: 2025-11-18
- `EditButton.svelte` - Edit button (floating) - `EditButton.svelte` - Edit button (floating)
- `FavoriteButton.svelte` - Toggle favorite - `FavoriteButton.svelte` - Toggle favorite
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category - `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
- `CardAdd.svelte` - Add recipe card placeholder
- `FormSection.svelte` - Styled form section wrapper - `FormSection.svelte` - Styled form section wrapper
- `Header.svelte` - Page header - `Header.svelte` - Page header
- `UserHeader.svelte` - User-specific header - `UserHeader.svelte` - User-specific header
@@ -190,7 +189,6 @@ Generated: 2025-11-18
#### Recipe-Specific Components #### Recipe-Specific Components
- `Recipes.svelte` - Recipe list display - `Recipes.svelte` - Recipe list display
- `RecipeEditor.svelte` - Recipe editing form
- `RecipeNote.svelte` - Recipe notes display - `RecipeNote.svelte` - Recipe notes display
- `EditRecipe.svelte` - Edit recipe modal - `EditRecipe.svelte` - Edit recipe modal
- `EditRecipeNote.svelte` - Edit recipe notes - `EditRecipeNote.svelte` - Edit recipe notes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.94.1", "version": "1.95.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
-434
View File
@@ -1,434 +0,0 @@
<script lang="ts">
import Cross from '$lib/assets/icons/Cross.svelte'
import { toast } from '$lib/js/toast.svelte'
import "$lib/css/shake.css"
import "$lib/css/icon.css"
import { onMount } from 'svelte'
let {
card_data = $bindable(),
image_preview_url = $bindable(''),
selected_image_file = $bindable<File | null>(null),
short_name = ''
}: {
card_data: any,
image_preview_url: string,
selected_image_file: File | null,
short_name: string
} = $props();
// Constants for validation
const ALLOWED_MIME_TYPES = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
// Handle file selection via onchange event
function handleFileSelect(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
// Validate MIME type
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
toast.error('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
input.value = '';
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
toast.error(`File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`);
input.value = '';
return;
}
// Clean up old preview URL if exists
if (image_preview_url && image_preview_url.startsWith('blob:')) {
URL.revokeObjectURL(image_preview_url);
}
// Create preview and store file
image_preview_url = URL.createObjectURL(file);
selected_image_file = file;
}
// Check if initial image_preview_url redirects to placeholder
onMount(() => {
if (image_preview_url && !image_preview_url.startsWith('blob:')) {
const img = new Image();
img.onload = () => {
// Check if this is the placeholder image (150x150)
if (img.naturalWidth === 150 && img.naturalHeight === 150) {
image_preview_url = ""
}
};
img.onerror = () => {
image_preview_url = ""
};
img.src = image_preview_url;
}
});
// Initialize tags if needed
if (!card_data.tags) {
card_data.tags = []
}
// Tag management
let new_tag = $state("");
// Reference to file input for clearing
let fileInput: HTMLInputElement;
function remove_selected_images() {
if (image_preview_url && image_preview_url.startsWith('blob:')) {
URL.revokeObjectURL(image_preview_url);
}
image_preview_url = "";
selected_image_file = null;
// Reset the file input
if (fileInput) {
fileInput.value = '';
}
}
function add_to_tags() {
if (new_tag && !card_data.tags.includes(new_tag)) {
card_data.tags = [...card_data.tags, new_tag];
}
new_tag = "";
}
function remove_from_tags(tag: string) {
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
}
function add_on_enter(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
add_to_tags();
}
}
function remove_on_enter(event: KeyboardEvent, tag: string) {
if (event.key === 'Enter') {
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
}
}
</script>
<style>
.card{
position: relative;
margin-inline: auto;
--card-width: 300px;
text-decoration: none;
position: relative;
box-sizing: border-box;
width: var(--card-width);
aspect-ratio: 4/7;
border-radius: var(--radius-card);
background-size: contain;
display: flex;
flex-direction: column;
justify-content: end;
transition: var(--transition-normal);
background-color: var(--blue);
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.3);
z-index: 0;
}
.img_label{
position :absolute;
z-index: 1;
top: 0;
width: 100%;
height: 100%;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
border-radius: 20px 20px 0 0 ;
transition: var(--transition-normal);
}
.img_label_wrapper:hover{
background-color: var(--red);
box-shadow: 0 2em 1em 0.5em rgba(0,0,0,0.3);
transform:scale(1.02, 1.02);
}
.img_label_wrapper{
position: absolute;
height: 50%;
width: 100%;
top:0;
left: 0;
border-radius: 20px 20px 0 0;
transition: var(--transition-normal);
}
.img_label_wrapper:hover .delete{
opacity: 100%;
}
.img_label svg{
width: 100px;
height: 100px;
fill: white;
transition: var(--transition-normal);
}
.delete{
cursor: pointer;
all: unset;
position: absolute;
top:2rem;
left: 2rem;
opacity: 0%;
z-index: 4;
transition: var(--transition-normal);
}
.delete:hover{
transform: scale(1.2, 1.2);
}
.upload{
z-index: 1;
}
.img_label:hover .upload{
transform: scale(1.2, 1.2);
z-index: 10;
}
#img_picker{
display: none;
width: 300px;
height: 300px;
position:absolute;
}
input{
all: unset;
}
input::placeholder{
all:unset;
}
.card .icon{
z-index: 3;
box-sizing: border-box;
text-decoration: unset;
text-align:center;
width: 2.6rem;
aspect-ratio: 1/1;
transition: var(--transition-fast);
position: absolute;
font-size: 1.5rem;
top:-0.5em;
right:-0.5em;
padding: 0.25em;
background-color: var(--nord6);
border-radius: var(--radius-pill);
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.6);
}
.card .icon:hover,
.card .icon:focus-visible
{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform:scale(1.2, 1.2)
}
.card:hover,
.card:focus-within{
transform: scale(1.02,1.02);
box-shadow: 0.2em 0.2em 2em 1em rgba(0, 0, 0, 0.3);
}
.card img{
height: 50%;
object-fit: cover;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.card .title {
position: relative;
box-sizing: border-box;
padding-top: 0.5em;
height: 50%;
width: 100% ;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: var(--transition-fast);
}
.card .name{
all: unset;
width:100%;
font-size: 2em;
color: white;
padding-inline: 0.5em;
padding-block: 0.2em;
}
.card .name:hover{
color:var(--nord0);
}
.card .description{
box-sizing:border-box;
border: 2px solid var(--nord5);
border-radius: 30px;
padding-inline: 1em;
padding-block: 0.5em;
margin-inline: 1em;
margin-top: 0;
color: var(--nord4);
width: calc(300px - 2em); /*??*/
}
.card .description:hover{
color: var(--nord0);
border: 2px solid var(--nord0);
}
.card .tags{
display: flex;
flex-wrap: wrap-reverse;
overflow: hidden;
column-gap: 0.25em;
padding-inline: 0.5em;
padding-top: 0.25em;
margin-bottom:0.5em;
flex-grow: 0;
}
.card .tag{
cursor: pointer;
text-decoration: unset;
background-color: var(--nord4);
color: var(--nord0);
border-radius: 100px;
padding-inline: 1em;
line-height: 1.5em;
margin-bottom: 0.5em;
transition: var(--transition-fast);
box-shadow: 0.2em 0.2em 0.2em 0.05em rgba(0, 0, 0, 0.3);
}
.card .tag:hover,
.card .tag:focus-visible,
.card .tag:focus-within
{
transform: scale(1.04, 1.04);
background-color: var(--nord8);
box-shadow: 0.2em 0.2em 0.2em 0.1em rgba(0, 0, 0, 0.3);
}
.card .title .category{
z-index: 2;
position: absolute;
box-shadow: 0em 0em 1em 0.1em rgba(0, 0, 0, 0.6);
text-decoration: none;
color: var(--nord6);
font-size: 1.5rem;
top: -0.8em;
left: -0.5em;
width: 10rem;
background-color: var(--nord0);
padding-inline: 1em;
border-radius: var(--radius-pill);
transition: var(--transition-fast);
}
.card .title .category:hover,
.card .title .category:focus-within
{
box-shadow: -0.2em 0.2em 1em 0.1em rgba(0, 0, 0, 0.6);
background-color: var(--nord3);
transform: scale(1.05, 1.05)
}
.card:hover .icon,
.card:focus-visible .icon
{
animation: shake 0.6s
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(-30deg)
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1,1);
}
}
.input_wrapper{
position: relative;
padding-left: 3rem;
padding-left: 40rem;
}
.input_wrapper > input{
margin-left: 1ch;
}
.input{
position:absolute;
top: -.1ch;
left: 0.6ch;
font-size: 1.6rem;
}
.tag_input{
width: 12ch;
}
</style>
<div class=card>
<input class=icon placeholder=🥫 bind:value={card_data.icon}/>
{#if image_preview_url}
<!-- svelte-ignore a11y_missing_attribute -->
<img src={image_preview_url} class=img_preview width=300px height=300px />
{/if}
<div class=img_label_wrapper>
{#if image_preview_url}
<button class=delete onclick={remove_selected_images}>
<Cross fill=white style="width:2rem;height:2rem;"></Cross>
</button>
{/if}
<label class=img_label for=img_picker>
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
</label>
</div>
<input type="file" id="img_picker" accept="image/webp,image/jpeg,image/jpg,image/png" onchange={handleFileSelect} bind:this={fileInput}>
<div class=title>
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
<div>
<input class=name placeholder=Name... bind:value={card_data.name}/>
<p contenteditable class=description placeholder=Kurzbeschreibung... bind:innerText={card_data.description}></p>
</div>
<div class=tags>
{#each card_data.tags as tag (tag)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="tag" role="button" tabindex="0" onkeydown={(event) => remove_on_enter(event, tag)} onclick={() => remove_from_tags(tag)} aria-label="Tag {tag} entfernen">{tag}</div>
{/each}
<div class="tag input_wrapper"><span class=input>+</span><input class="tag_input" type="text" onkeydown={add_on_enter} onfocusout={add_to_tags} size="1" bind:value={new_tag} placeholder=Stichwort...></div>
</div>
</div>
</div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Cross from '$lib/assets/icons/Cross.svelte'; import Cross from '$lib/assets/icons/Cross.svelte';
import ImageEditor from '$lib/components/recipes/ImageEditor.svelte';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
@@ -53,9 +54,34 @@
input.value = ''; input.value = '';
return; return;
} }
openEditor(file);
input.value = '';
}
// Photo editor (crop / scale / webp quality) state
let editorFile = $state<File | null>(null);
let editorOpen = $state(false);
function openEditor(file: File) {
editorFile = file;
editorOpen = true;
}
function closeEditor() {
editorOpen = false;
editorFile = null;
if (fileInput) fileInput.value = '';
}
function handleEditorApply(file: File, url: string) {
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url); if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
image_preview_url = URL.createObjectURL(file);
selected_image_file = file; selected_image_file = file;
image_preview_url = url;
closeEditor();
}
function editCurrentImage() {
if (selected_image_file) openEditor(selected_image_file);
} }
function clearSelectedImage() { function clearSelectedImage() {
@@ -129,15 +155,30 @@
</div> </div>
</button> </button>
{#if selected_image_file} {#if selected_image_file}
<button <div class="img-controls">
type="button" <button
class="clear-img" type="button"
onclick={clearSelectedImage} class="img-btn"
title="Auswahl verwerfen" onclick={editCurrentImage}
aria-label="Auswahl verwerfen" title="Bild bearbeiten"
> aria-label="Bild bearbeiten"
<Cross fill="white" width="1.25rem" height="1.25rem" /> >
</button> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true">
<path
d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"
/>
</svg>
</button>
<button
type="button"
class="img-btn danger"
onclick={clearSelectedImage}
title="Auswahl verwerfen"
aria-label="Auswahl verwerfen"
>
<Cross fill="white" width="1.15rem" height="1.15rem" />
</button>
</div>
{/if} {/if}
<input <input
bind:this={fileInput} bind:this={fileInput}
@@ -215,6 +256,10 @@
</div> </div>
</section> </section>
{#if editorOpen && editorFile}
<ImageEditor file={editorFile} onApply={handleEditorApply} onCancel={closeEditor} />
{/if}
<style> <style>
.section { .section {
--scale: 0.3; --scale: 0.3;
@@ -312,10 +357,18 @@
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
} }
.clear-img { /* Edit / remove controls — top-right of the image, offset below the fixed
site header (height 3rem, top max(12px, safe-area+4px)) so the nav never
obstructs them. */
.img-controls {
position: absolute; position: absolute;
top: calc(1rem + env(safe-area-inset-top, 0px)); top: calc(max(12px, env(safe-area-inset-top, 0px) + 4px) + 3rem + 1rem);
right: 1rem; right: 1rem;
display: flex;
gap: 0.5rem;
z-index: 5;
}
.img-btn {
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
border: none; border: none;
width: 2.5rem; width: 2.5rem;
@@ -324,17 +377,26 @@
display: grid; display: grid;
place-items: center; place-items: center;
cursor: pointer; cursor: pointer;
z-index: 5;
transition: transition:
transform 150ms ease, transform 150ms ease,
background 150ms ease; background 150ms ease;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
box-shadow: var(--shadow-sm);
} }
.clear-img:hover, .img-btn svg {
.clear-img:focus-visible { width: 1.15rem;
background: var(--red); height: 1.15rem;
fill: white;
}
.img-btn:hover,
.img-btn:focus-visible {
background: var(--color-primary);
transform: scale(1.08); transform: scale(1.08);
} }
.img-btn.danger:hover,
.img-btn.danger:focus-visible {
background: var(--red);
}
.file-input { .file-input {
position: absolute; position: absolute;
@@ -0,0 +1,797 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
loadBitmap,
renderToBlob,
fitWithin,
blobToFile,
formatBytes,
type CropRect
} from '$lib/js/imageEdit';
type Props = {
file: File;
shortName?: string;
onApply: (file: File, url: string) => void;
onCancel: () => void;
};
let { file, shortName = '', onApply, onCancel }: Props = $props();
const MIN_CROP = 24; // minimum crop edge, source px
const RATIOS = [
{ key: 'free', label: 'Frei' },
{ key: 'orig', label: 'Original' },
{ key: '1:1', label: '1:1', value: 1 },
{ key: '4:3', label: '4:3', value: 4 / 3 },
{ key: '3:2', label: '3:2', value: 3 / 2 },
{ key: '16:9', label: '16:9', value: 16 / 9 }
] as const;
const RES_PRESETS = [1000, 1500, 2000, 0]; // 0 = Original
let bitmap = $state<ImageBitmap | null>(null);
let imgW = $state(0);
let imgH = $state(0);
let loadError = $state('');
let crop = $state<CropRect>({ x: 0, y: 0, w: 0, h: 0 });
let ratioMode = $state<string>('free');
let maxRes = $state(2000);
let quality = $state(92);
// Live-encode output
let outBlob = $state<Blob | null>(null);
let outUrl = $state('');
let outW = $state(0);
let outH = $state(0);
let encoding = $state(false);
// Stage measurement
let stageW = $state(0);
let stageH = $state(0);
let stageCanvas = $state<HTMLCanvasElement | null>(null);
const activeRatio = $derived.by(() => {
const r = RATIOS.find((x) => x.key === ratioMode);
if (!r) return null;
if (r.key === 'orig') return imgH ? imgW / imgH : null;
return 'value' in r ? r.value : null;
});
// Fit the source image into the available stage area (display pixels).
const displayScale = $derived.by(() => {
if (!imgW || !imgH || !stageW || !stageH) return 1;
const availW = Math.max(1, stageW - 24);
const availH = Math.max(1, stageH - 24);
return Math.min(availW / imgW, availH / imgH);
});
const dispW = $derived(Math.round(imgW * displayScale));
const dispH = $derived(Math.round(imgH * displayScale));
function clamp(v: number, lo: number, hi: number) {
return Math.max(lo, Math.min(hi, v));
}
onMount(() => {
let cancelled = false;
(async () => {
try {
const bm = await loadBitmap(file);
if (cancelled) {
bm.close?.();
return;
}
bitmap = bm;
imgW = bm.width;
imgH = bm.height;
crop = { x: 0, y: 0, w: bm.width, h: bm.height };
} catch {
loadError = 'Bild konnte nicht geladen werden.';
}
})();
return () => {
cancelled = true;
};
});
// Draw the source onto the display canvas whenever it or the layout changes.
$effect(() => {
const cv = stageCanvas;
const bm = bitmap;
const w = dispW;
const h = dispH;
if (!cv || !bm || w <= 0 || h <= 0) return;
cv.width = w;
cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.clearRect(0, 0, w, h);
ctx.drawImage(bm, 0, 0, imgW, imgH, 0, 0, w, h);
});
// Debounced live encode — runs whenever crop / resolution / quality change.
let encodeToken = 0;
$effect(() => {
const bm = bitmap;
if (!bm) return;
const c = { ...crop };
const mr = maxRes;
const q = quality;
const token = ++encodeToken;
encoding = true;
const timer = setTimeout(async () => {
try {
const blob = await renderToBlob(bm, c, mr, q);
if (token !== encodeToken) return;
const size = fitWithin(c.w, c.h, mr);
if (outUrl) URL.revokeObjectURL(outUrl);
outBlob = blob;
outUrl = URL.createObjectURL(blob);
outW = size.w;
outH = size.h;
} catch {
/* transient encode failure — next change retries */
} finally {
if (token === encodeToken) encoding = false;
}
}, 200);
return () => clearTimeout(timer);
});
$effect(() => {
return () => {
if (outUrl) URL.revokeObjectURL(outUrl);
bitmap?.close?.();
};
});
// --- Crop drag handling ---
type Drag = { handle: string; hx: number; hy: number; px: number; py: number; start: CropRect };
let drag: Drag | null = null;
function startDrag(e: PointerEvent, handle: string, hx: number, hy: number) {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
drag = { handle, hx, hy, px: e.clientX, py: e.clientY, start: { ...crop } };
}
function onPointerMove(e: PointerEvent) {
if (!drag || displayScale === 0) return;
const ddx = (e.clientX - drag.px) / displayScale;
const ddy = (e.clientY - drag.py) / displayScale;
const s = drag.start;
if (drag.handle === 'move') {
crop = {
x: clamp(s.x + ddx, 0, imgW - s.w),
y: clamp(s.y + ddy, 0, imgH - s.h),
w: s.w,
h: s.h
};
return;
}
let left = s.x;
let top = s.y;
let right = s.x + s.w;
let bottom = s.y + s.h;
if (drag.hx === 1) right = s.x + s.w + ddx;
else if (drag.hx === -1) left = s.x + ddx;
if (drag.hy === 1) bottom = s.y + s.h + ddy;
else if (drag.hy === -1) top = s.y + ddy;
const r = activeRatio;
if (r) {
if (drag.hx !== 0 && drag.hy !== 0) {
const nw = Math.max(MIN_CROP, right - left);
const nh = nw / r;
if (drag.hy === 1) bottom = top + nh;
else top = bottom - nh;
} else if (drag.hx !== 0) {
const cy = s.y + s.h / 2;
const nh = Math.max(MIN_CROP, right - left) / r;
top = cy - nh / 2;
bottom = cy + nh / 2;
} else if (drag.hy !== 0) {
const cx = s.x + s.w / 2;
const nw = Math.max(MIN_CROP, bottom - top) * r;
left = cx - nw / 2;
right = cx + nw / 2;
}
}
left = Math.max(0, left);
top = Math.max(0, top);
right = Math.min(imgW, right);
bottom = Math.min(imgH, bottom);
if (right - left < MIN_CROP) {
if (drag.hx === -1) left = right - MIN_CROP;
else right = left + MIN_CROP;
}
if (bottom - top < MIN_CROP) {
if (drag.hy === -1) top = bottom - MIN_CROP;
else bottom = top + MIN_CROP;
}
left = Math.max(0, left);
top = Math.max(0, top);
right = Math.min(imgW, right);
bottom = Math.min(imgH, bottom);
crop = { x: left, y: top, w: right - left, h: bottom - top };
}
function endDrag() {
// Pointer capture is released implicitly on pointerup.
drag = null;
}
function selectRatio(key: string) {
ratioMode = key;
const r = RATIOS.find((x) => x.key === key);
const value = r && r.key === 'orig' ? imgW / imgH : r && 'value' in r ? r.value : null;
if (!value) return; // 'free' keeps the current crop
// Fit a centred rect of this ratio inside the current crop.
const cx = crop.x + crop.w / 2;
const cy = crop.y + crop.h / 2;
let nw = crop.w;
let nh = nw / value;
if (nh > crop.h) {
nh = crop.h;
nw = nh * value;
}
nw = Math.min(nw, imgW);
nh = Math.min(nh, imgH);
crop = {
x: clamp(cx - nw / 2, 0, imgW - nw),
y: clamp(cy - nh / 2, 0, imgH - nh),
w: nw,
h: nh
};
}
function resetCrop() {
ratioMode = 'free';
crop = { x: 0, y: 0, w: imgW, h: imgH };
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
}
function apply() {
if (!outBlob || !outUrl) return;
const url = outUrl;
const f = blobToFile(outBlob, shortName);
outUrl = ''; // hand the object URL off to the caller; don't revoke it
onApply(f, url);
}
const handles = [
{ key: 'nw', hx: -1, hy: -1 },
{ key: 'n', hx: 0, hy: -1 },
{ key: 'ne', hx: 1, hy: -1 },
{ key: 'e', hx: 1, hy: 0 },
{ key: 'se', hx: 1, hy: 1 },
{ key: 's', hx: 0, hy: 1 },
{ key: 'sw', hx: -1, hy: 1 },
{ key: 'w', hx: -1, hy: 0 }
];
</script>
<svelte:window onkeydown={onKeydown} />
<div
class="backdrop"
role="dialog"
aria-modal="true"
aria-label="Bild bearbeiten"
tabindex="-1"
>
<button type="button" class="scrim" aria-label="Schliessen" onclick={onCancel}></button>
<div class="panel">
<header class="panel-head">
<h2>Bild bearbeiten</h2>
<button type="button" class="ghost" onclick={onCancel} aria-label="Abbrechen"></button>
</header>
<div class="body">
<!-- Stage -->
<div class="stage" bind:clientWidth={stageW} bind:clientHeight={stageH}>
{#if loadError}
<p class="stage-msg">{loadError}</p>
{:else if !bitmap}
<p class="stage-msg">Lade Bild…</p>
{:else}
<div class="frame" style:width="{dispW}px" style:height="{dispH}px">
<canvas bind:this={stageCanvas}></canvas>
<div
class="crop"
style:left="{crop.x * displayScale}px"
style:top="{crop.y * displayScale}px"
style:width="{crop.w * displayScale}px"
style:height="{crop.h * displayScale}px"
role="application"
aria-label="Zuschneidebereich verschieben"
onpointerdown={(e) => startDrag(e, 'move', 0, 0)}
onpointermove={onPointerMove}
onpointerup={endDrag}
onpointercancel={endDrag}
>
<span class="third v1"></span>
<span class="third v2"></span>
<span class="third h1"></span>
<span class="third h2"></span>
{#each handles as h (h.key)}
<button
type="button"
class="handle h-{h.key}"
aria-label="Ziehpunkt {h.key}"
onpointerdown={(e) => startDrag(e, h.key, h.hx, h.hy)}
></button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Controls -->
<div class="rail">
<div class="preview">
<div class="preview-img" class:busy={encoding}>
{#if outUrl}
<!-- svelte-ignore a11y_missing_attribute -->
<img src={outUrl} />
{/if}
</div>
<dl class="stats">
<div><dt>Auflösung</dt><dd>{outW || '—'} × {outH || '—'}</dd></div>
<div>
<dt>Dateigrösse</dt>
<dd class="size">{outBlob ? formatBytes(outBlob.size) : '—'}</dd>
</div>
</dl>
</div>
<fieldset class="group">
<legend>Seitenverhältnis</legend>
<div class="chips">
{#each RATIOS as r (r.key)}
<button
type="button"
class="chip"
class:active={ratioMode === r.key}
onclick={() => selectRatio(r.key)}>{r.label}</button
>
{/each}
</div>
</fieldset>
<fieldset class="group">
<legend>Max. Auflösung</legend>
<div class="chips">
{#each RES_PRESETS as p (p)}
<button
type="button"
class="chip"
class:active={maxRes === p}
onclick={() => (maxRes = p)}>{p === 0 ? 'Original' : p}</button
>
{/each}
</div>
<label class="custom">
<span>Eigene Kante</span>
<input type="number" min="0" step="50" bind:value={maxRes} />
<span class="unit">px</span>
</label>
</fieldset>
<fieldset class="group">
<legend>WebP-Qualität</legend>
<div class="quality">
<input type="range" min="1" max="100" step="1" bind:value={quality} />
<output>{quality}</output>
</div>
</fieldset>
<button type="button" class="reset" onclick={resetCrop}>Zuschnitt zurücksetzen</button>
</div>
</div>
<footer class="panel-foot">
<button type="button" class="btn ghost-btn" onclick={onCancel}>Abbrechen</button>
<button type="button" class="btn primary" disabled={!outBlob} onclick={apply}>
Übernehmen
</button>
</footer>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: clamp(0px, 2vw, 1.5rem);
}
.scrim {
all: unset;
position: absolute;
inset: 0;
background: rgba(10, 14, 20, 0.6);
backdrop-filter: blur(4px);
cursor: pointer;
}
.panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(1100px, 100%);
height: min(760px, 100%);
max-height: 100%;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.panel-head,
.panel-foot {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.85rem 1.25rem;
flex-shrink: 0;
}
.panel-head {
justify-content: space-between;
border-bottom: 1px solid var(--color-border);
}
.panel-head h2 {
margin: 0;
font-size: var(--text-lg, 1.2rem);
color: var(--color-text-primary);
}
.panel-foot {
justify-content: flex-end;
border-top: 1px solid var(--color-border);
}
.ghost {
all: unset;
cursor: pointer;
color: var(--color-text-secondary);
font-size: 1.1rem;
line-height: 1;
padding: 0.35rem;
border-radius: var(--radius-sm);
}
.ghost:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.body {
display: grid;
grid-template-columns: 1fr 320px;
flex: 1;
min-height: 0;
}
/* Stage */
.stage {
position: relative;
display: grid;
place-items: center;
min-height: 0;
background:
repeating-conic-gradient(var(--color-bg-secondary) 0% 25%, transparent 0% 50%) 50% / 24px 24px;
background-color: var(--color-bg-tertiary);
overflow: hidden;
}
.stage-msg {
color: var(--color-text-secondary);
font-size: var(--text-sm);
}
.frame {
position: relative;
touch-action: none;
box-shadow: var(--shadow-md);
}
.frame canvas {
display: block;
width: 100%;
height: 100%;
}
.crop {
position: absolute;
box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
cursor: move;
touch-action: none;
}
.third {
position: absolute;
background: rgba(255, 255, 255, 0.35);
pointer-events: none;
}
.third.v1,
.third.v2 {
top: 0;
bottom: 0;
width: 1px;
}
.third.v1 {
left: 33.33%;
}
.third.v2 {
left: 66.66%;
}
.third.h1,
.third.h2 {
left: 0;
right: 0;
height: 1px;
}
.third.h1 {
top: 33.33%;
}
.third.h2 {
top: 66.66%;
}
.handle {
all: unset;
position: absolute;
width: 14px;
height: 14px;
box-sizing: border-box;
background: var(--color-primary);
border: 2px solid white;
border-radius: 50%;
cursor: pointer;
touch-action: none;
}
.h-nw {
top: -7px;
left: -7px;
cursor: nwse-resize;
}
.h-ne {
top: -7px;
right: -7px;
cursor: nesw-resize;
}
.h-se {
bottom: -7px;
right: -7px;
cursor: nwse-resize;
}
.h-sw {
bottom: -7px;
left: -7px;
cursor: nesw-resize;
}
.h-n {
top: -7px;
left: calc(50% - 7px);
cursor: ns-resize;
}
.h-s {
bottom: -7px;
left: calc(50% - 7px);
cursor: ns-resize;
}
.h-e {
right: -7px;
top: calc(50% - 7px);
cursor: ew-resize;
}
.h-w {
left: -7px;
top: calc(50% - 7px);
cursor: ew-resize;
}
/* Rail */
.rail {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 1.1rem;
overflow-y: auto;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
.preview {
display: flex;
gap: 0.85rem;
align-items: center;
}
.preview-img {
flex-shrink: 0;
width: 96px;
height: 96px;
border-radius: var(--radius-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
overflow: hidden;
display: grid;
place-items: center;
transition: opacity 150ms ease;
}
.preview-img.busy {
opacity: 0.55;
}
.preview-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
.stats {
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stats dt {
font-size: var(--text-sm, 0.8rem);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.stats dd {
margin: 0;
font-size: var(--text-md, 1rem);
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.stats dd.size {
font-weight: 700;
color: var(--color-primary);
}
.group {
border: none;
margin: 0;
padding: 0;
min-width: 0;
}
.group legend {
padding: 0;
font-size: var(--text-sm, 0.8rem);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
all: unset;
cursor: pointer;
padding: 0.35rem 0.7rem;
border-radius: var(--radius-pill);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-sm, 0.85rem);
transition: var(--transition-fast);
}
.chip:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.chip.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-text-on-primary);
}
.custom {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: var(--text-sm, 0.85rem);
color: var(--color-text-secondary);
}
.custom input {
width: 6ch;
padding: 0.3rem 0.5rem;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
font: inherit;
}
.custom .unit {
color: var(--color-text-tertiary);
}
.quality {
display: flex;
align-items: center;
gap: 0.75rem;
}
.quality input[type='range'] {
flex: 1;
accent-color: var(--color-primary);
}
.quality output {
min-width: 2.5ch;
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--color-text-primary);
}
.reset {
all: unset;
cursor: pointer;
text-align: center;
padding: 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-size: var(--text-sm, 0.85rem);
transition: var(--transition-fast);
}
.reset:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.btn {
all: unset;
cursor: pointer;
padding: 0.6rem 1.4rem;
border-radius: var(--radius-pill);
font-weight: 600;
transition: var(--transition-fast);
}
.ghost-btn {
color: var(--color-text-secondary);
}
.ghost-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.primary {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.primary:hover {
background: var(--color-primary-hover);
}
.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 760px) {
.panel {
height: 100%;
border-radius: 0;
}
.body {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
}
.rail {
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 45dvh;
}
}
</style>
@@ -1,81 +0,0 @@
<script lang="ts">
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
let {
card_data = $bindable({}),
seasonRanges = $bindable([]),
ingredients = $bindable([]),
instructions = $bindable([])
}: {
card_data?: any,
seasonRanges?: any[],
ingredients?: any[],
instructions?: any[]
} = $props();
let short_name = $state('');
let password = $state('');
let datecreated = $state(new Date());
let datemodified = $state(datecreated);
let result = $state('');
let image_preview_url = $state('');
let selected_image_file = $state<File | null>(null);
async function doPost () {
const res = await fetch('/api/add', {
method: 'POST',
body: JSON.stringify({
recipe: {
seasonRanges: seasonRanges,
...card_data,
images: [{
mediapath: short_name + '.webp',
alt: "",
caption: ""
}],
short_name,
datecreated,
datemodified,
instructions,
ingredients,
},
headers: {
'content-type': 'application/json',
bearer: password,
}
})
})
const json = await res.json()
result = JSON.stringify(json)
}
</script>
<style>
input.temp{
all: unset;
display: block;
margin: 1rem auto;
padding: 0.2em 1em;
border-radius: var(--radius-pill);
background-color: var(--nord4);
}
</style>
<CardAdd bind:card_data={card_data} bind:image_preview_url={image_preview_url} bind:selected_image_file={selected_image_file} {short_name}></CardAdd>
<input class=temp bind:value={short_name} placeholder="Kurzname"/>
<SeasonSelect bind:ranges={seasonRanges} />
<button onclick={() => console.log(seasonRanges)}>PRINTOUT season</button>
<h2>Zutaten</h2>
<CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList>
<h2>Zubereitung</h2>
<CreateStepList bind:instructions={instructions} add_info={{}}></CreateStepList>
<input class=temp type="password" placeholder=Passwort bind:value={password}>
+81
View File
@@ -0,0 +1,81 @@
/**
* Client-side image editing pipeline (no DOM / Svelte deps).
*
* The browser already ships a WebP encoder via `canvas.toBlob(cb, 'image/webp', q)`.
* Crop, scale-to-fit and the size readout are built on top of `<canvas>`; the
* encode is the only primitive we don't hand-roll. `sharp` cannot run here — it's
* a native Node binding — so all of this happens on the main thread.
*/
export type CropRect = { x: number; y: number; w: number; h: number };
export type Size = { w: number; h: number };
/**
* Decode a File into an ImageBitmap, honouring EXIF orientation so that
* sideways phone photos render upright.
*/
export async function loadBitmap(file: File): Promise<ImageBitmap> {
try {
return await createImageBitmap(file, { imageOrientation: 'from-image' });
} catch {
// Older Safari ignores the options bag — fall back to the plain call.
return await createImageBitmap(file);
}
}
/**
* Scale `w`×`h` to fit inside a `max`×`max` box, preserving aspect ratio.
* Never upscales (a smaller source is returned untouched). `max <= 0` means
* "no limit" (Original).
*/
export function fitWithin(w: number, h: number, max: number): Size {
if (max <= 0 || (w <= max && h <= max)) {
return { w: Math.round(w), h: Math.round(h) };
}
const scale = Math.min(max / w, max / h);
return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) };
}
/**
* Crop `bitmap` to `crop` (source pixels), scale the result to fit `maxRes`,
* and encode as WebP at `quality` (1100). Returns the encoded Blob; read
* `.size` for the final byte count.
*/
export async function renderToBlob(
bitmap: ImageBitmap,
crop: CropRect,
maxRes: number,
quality: number
): Promise<Blob> {
const out = fitWithin(crop.w, crop.h, maxRes);
const canvas = document.createElement('canvas');
canvas.width = out.w;
canvas.height = out.h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D canvas context unavailable');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, crop.x, crop.y, crop.w, crop.h, 0, 0, out.w, out.h);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('WebP encoding failed'))),
'image/webp',
Math.min(1, Math.max(0.01, quality / 100))
);
});
}
/** Wrap an encoded Blob as a File the form can upload. */
export function blobToFile(blob: Blob, shortName: string): File {
const base = (shortName || 'image').trim() || 'image';
return new File([blob], `${base}.webp`, { type: 'image/webp' });
}
/** Human-readable byte size, e.g. "412 KB" / "1.3 MB". */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
@@ -2,15 +2,14 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { tick } from 'svelte'; import { tick } from 'svelte';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import Check from '$lib/assets/icons/Check.svelte'; import SaveFab from '$lib/components/SaveFab.svelte';
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte'; import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
import TranslationApproval from '$lib/components/recipes/TranslationApproval.svelte'; import TranslationApproval from '$lib/components/recipes/TranslationApproval.svelte';
import CardAdd from '$lib/components/recipes/CardAdd.svelte'; import EditTitleImgParallax from '$lib/components/recipes/EditTitleImgParallax.svelte';
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte'; import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte'; import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
import Toggle from '$lib/components/Toggle.svelte'; import Toggle from '$lib/components/Toggle.svelte';
import '$lib/css/action_button.css';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -87,20 +86,30 @@
defaultForm, defaultForm,
}); });
// Show translation workflow before submission function validate(): boolean {
function prepareSubmit() {
// Client-side validation
if (!short_name.trim()) { if (!short_name.trim()) {
toast.error('Bitte geben Sie einen Kurznamen ein'); toast.error('Bitte geben Sie einen Kurznamen ein');
return; return false;
} }
if (!card_data.name) { if (!card_data.name) {
toast.error('Bitte geben Sie einen Namen ein'); toast.error('Bitte geben Sie einen Namen ein');
return; return false;
} }
return true;
}
// Create directly without an English translation (mirrors /edit's SaveFab).
async function saveRecipe() {
if (!validate()) return;
translationData = null;
await tick();
formElement?.requestSubmit();
}
// Open the optional translation workflow before submission.
function openTranslation() {
if (!validate()) return;
showTranslationWorkflow = true; showTranslationWorkflow = true;
// Scroll to translation section
setTimeout(() => { setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' }); document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100); }, 100);
@@ -147,141 +156,322 @@
</script> </script>
<style> <style>
input { h3 {
display: block; text-align: center;
border: unset; font-size: 1.15rem;
margin: 1rem auto; letter-spacing: 0.02em;
padding: 0.5em 1em; margin-block: 1.25rem 0.75rem;
border-radius: var(--radius-pill); color: var(--color-text-primary);
background-color: var(--nord4); }
font-size: 1.1rem;
transition: var(--transition-fast); /* ===== Below-hero content wrapper: full-width backdrop hides the sticky hero ===== */
} .below-hero {
input:hover, --bg-color: var(--color-bg-primary);
input:focus-visible { position: relative;
scale: 1.05 1.05; max-width: 1000px;
} margin: 0 auto;
.list_wrapper { padding: 2rem 1rem 4rem;
margin-inline: auto; }
display: flex; .below-hero::before {
flex-direction: row; content: '';
max-width: 1000px; position: absolute;
gap: 2rem; inset: 0;
justify-content: center; left: 50%;
} transform: translateX(-50%);
@media screen and (max-width: 700px) { width: 100vw;
.list_wrapper { background-color: var(--bg-color);
z-index: -1;
}
/* ===== Title-card extras (inside hero card) ===== */
.section-label {
font-size: 1.1rem;
font-weight: 700;
text-align: center;
margin-block: 1.25rem 0.5rem;
color: var(--color-text-primary);
}
.season-wrapper {
margin-block: 0.25rem 0.75rem;
}
.preamble {
margin: 0.5rem 0 0.25rem;
padding: 1em 1.25em;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 1rem;
min-height: 3em;
outline: none;
transition: border-color 200ms ease;
}
.preamble:focus,
.preamble:hover {
border-color: var(--color-primary);
}
.preamble:empty::before {
content: attr(data-placeholder);
color: var(--color-text-tertiary);
font-style: italic;
}
/* ===== Meta row under the hero: URL + base-recipe toggle ===== */
.meta-row {
display: flex;
gap: 1.5rem 2rem;
align-items: flex-end;
justify-content: center;
flex-wrap: wrap;
margin-block: 0.5rem 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-border);
}
.url-field {
display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-secondary);
font-weight: 700;
} }
} .url-field input {
h1 { display: block;
text-align: center; border: 1px solid var(--color-border);
margin-bottom: 2rem; margin: 0;
} padding: 0.55em 1.1em;
.title_container { border-radius: var(--radius-pill);
max-width: 1000px; background-color: var(--color-bg-tertiary);
display: flex; color: var(--color-text-primary);
flex-direction: column; font-size: 1rem;
margin-inline: auto; font-weight: 400;
} letter-spacing: 0;
.title { text-transform: none;
position: relative; min-width: 16rem;
width: min(800px, 80vw); transition: var(--transition-fast);
margin-block: 2rem; }
margin-inline: auto; .url-field input:hover,
background-color: var(--nord6); .url-field input:focus-visible {
padding: 1rem 2rem; border-color: var(--color-primary);
} outline: none;
.title p { }
border: 2px solid var(--nord1); .toggle-field {
border-radius: 10000px; align-self: center;
padding: 0.5em 1em; }
font-size: 1.1rem;
transition: var(--transition-normal); /* ===== Ingredients + Instructions two-col ===== */
} .list_wrapper {
.title p:hover, margin-inline: auto;
.title p:focus-within { display: flex;
scale: 1.02 1.02; flex-direction: row;
} max-width: 1000px;
.addendum { gap: 2rem;
font-size: 1.1rem; justify-content: center;
max-width: 90%; margin-block: 2.5rem;
margin-inline: auto; }
border: 2px solid var(--nord1); @media screen and (max-width: 700px) {
border-radius: 45px; .list_wrapper {
padding: 1em 1em; flex-direction: column;
transition: var(--transition-fast); gap: 1rem;
} }
.addendum:hover, }
.addendum:focus-within {
scale: 1.02 1.02; /* ===== Addendum ===== */
} .addendum_wrapper {
.addendum_wrapper { max-width: 1000px;
max-width: 1000px; margin: 2.5rem auto;
margin-inline: auto; }
} .addendum {
h3 { font-size: 1.05rem;
text-align: center; max-width: min(720px, 100%);
} margin-inline: auto;
button.action_button { padding: 1em 1.25em;
animation: unset !important; background: var(--color-bg-primary);
font-size: 1.3rem; border: 1px solid var(--color-border);
color: white; border-radius: var(--radius-md);
} color: var(--color-text-primary);
.submit_buttons { min-height: 3em;
display: flex; outline: none;
margin-inline: auto; transition: border-color 200ms ease;
max-width: 1000px; }
margin-block: 1rem; .addendum:hover,
justify-content: center; .addendum:focus-visible {
align-items: center; border-color: var(--color-primary);
gap: 2rem; }
} .addendum:empty::before {
.submit_buttons p { content: attr(data-placeholder);
padding: 0; color: var(--color-text-tertiary);
padding-right: 0.5em; font-style: italic;
margin: 0; }
}
@media (prefers-color-scheme: dark) { /* ===== Form-size / Backform ===== */
:global(:root:not([data-theme="light"])) .title { .form-size-section {
background-color: var(--nord6-dark); max-width: 600px;
margin: 2rem auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.form-size-head {
padding: 0.75rem 1rem;
}
.form-size-title {
font-weight: 600;
font-size: var(--text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
}
.form-size-body {
padding: 0.25rem 1rem 1rem;
border-top: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-shape-row {
display: flex;
gap: 0.4rem;
margin-top: 0.75rem;
}
.form-shape-row .shape-tile {
flex: 1 1 0;
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
padding: 0;
background: var(--color-bg-tertiary);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
color: var(--color-text-secondary);
transition: all 150ms ease;
}
.form-shape-row .shape-tile:hover,
.form-shape-row .shape-tile:focus-visible {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
color: var(--color-text-primary);
outline: none;
}
.form-shape-row .shape-tile[aria-checked="true"] {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-tertiary));
color: var(--color-primary);
}
.form-shape-row .shape-tile svg {
width: 1.25rem;
height: 1.25rem;
}
.form-size-inputs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 0.75rem;
}
.form-size-inputs .input-wrap {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.form-size-inputs .input-label {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-text-tertiary);
text-transform: uppercase;
}
.form-size-inputs .input-box {
position: relative;
display: flex;
align-items: center;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.form-size-inputs .input-box:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.form-size-inputs .input-box input {
flex: 1;
width: 100%;
padding: 0.55rem 2.25rem 0.55rem 0.75rem;
border: none;
background: transparent;
color: var(--color-text-primary);
font: inherit;
font-size: 1rem;
outline: none;
}
.form-size-inputs .input-box input::-webkit-outer-spin-button,
.form-size-inputs .input-box input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.form-size-inputs .input-box input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
.form-size-inputs .input-suffix {
position: absolute;
right: 0.75rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-tertiary);
pointer-events: none;
letter-spacing: 0.02em;
}
@media (max-width: 560px) {
.form-size-head { padding: 0.65rem 0.75rem; }
.form-size-body { padding: 0.25rem 0.75rem 0.85rem; }
.form-shape-row .shape-tile { height: 2rem; }
.form-shape-row .shape-tile svg { width: 1.1rem; height: 1.1rem; }
.form-size-inputs { grid-template-columns: 1fr 1fr; }
}
/* ===== Translation trigger ===== */
.translation-section-trigger {
max-width: 1000px;
margin: 2.5rem auto;
text-align: center;
}
.section-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.section-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-pill);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.section-btn:hover {
opacity: 0.85;
}
.section-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
background: var(--red);
color: white;
padding: 1rem;
border-radius: var(--radius-md);
margin: 1rem auto;
max-width: 800px;
text-align: center;
} }
}
:global(:root[data-theme="dark"]) .title {
background-color: var(--nord6-dark);
}
.form-size-section {
max-width: 600px;
margin: 1rem auto;
text-align: center;
}
.form-size-controls {
display: flex;
gap: 1.5rem;
justify-content: center;
margin-bottom: 0.5rem;
}
.form-size-inputs {
display: flex;
gap: 1rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
.form-size-inputs input[type="number"] {
width: 4em;
display: inline;
margin: 0 0.3em;
}
.error-message {
background: var(--nord11);
color: var(--nord6);
padding: 1rem;
border-radius: 4px;
margin: 1rem auto;
max-width: 800px;
text-align: center;
}
</style> </style>
<svelte:head> <svelte:head>
@@ -289,8 +479,6 @@ button.action_button {
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" /> <meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
</svelte:head> </svelte:head>
<h1>Rezept erstellen</h1>
{#if form?.error} {#if form?.error}
<div class="error-message"> <div class="error-message">
<strong>Fehler:</strong> {form.error} <strong>Fehler:</strong> {form.error}
@@ -330,16 +518,6 @@ button.action_button {
})} /> })} />
{/if} {/if}
<CardAdd
bind:card_data
bind:image_preview_url
bind:selected_image_file
short_name={short_name}
/>
<h3>Kurzname (für URL):</h3>
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
<!-- Hidden inputs for card data --> <!-- Hidden inputs for card data -->
<input type="hidden" name="name" value={card_data.name} /> <input type="hidden" name="name" value={card_data.name} />
<input type="hidden" name="description" value={card_data.description} /> <input type="hidden" name="description" value={card_data.description} />
@@ -348,99 +526,187 @@ button.action_button {
<input type="hidden" name="portions" value={portions_local} /> <input type="hidden" name="portions" value={portions_local} />
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} /> <input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
<input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} /> <input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} />
<input type="hidden" name="preamble" value={preamble} />
<div style="text-align: center; margin: 1rem;"> <EditTitleImgParallax
<Toggle bind:card_data
bind:checked={isBaseRecipe} bind:image_preview_url
label="Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)" bind:selected_image_file
/> >
</div> {#snippet titleExtras()}
<h2 class="section-label">Saison</h2>
<!-- Default Form (Cake Pan) --> <div class="season-wrapper">
<div class="form-size-section">
<h3>Backform (Standard):</h3>
<div class="form-size-controls">
<label>
<input type="radio" name="formShape" value="none" checked={!defaultForm} onchange={() => { defaultForm = null; }
} />
Keine
</label>
<label>
<input type="radio" name="formShape" value="round" checked={defaultForm?.shape === 'round'} onchange={() => { defaultForm = { shape: 'round', diameter: defaultForm?.diameter || 26 }; }
} />
Rund
</label>
<label>
<input type="radio" name="formShape" value="rectangular" checked={defaultForm?.shape === 'rectangular'} onchange={() => { defaultForm = { shape: 'rectangular', width: defaultForm?.width || 20, length: defaultForm?.length || 30 }; }
} />
Rechteckig
</label>
<label>
<input type="radio" name="formShape" value="gugelhupf" checked={defaultForm?.shape === 'gugelhupf'} onchange={() => { defaultForm = { shape: 'gugelhupf', diameter: defaultForm?.diameter || 24, innerDiameter: defaultForm?.innerDiameter || 8 }; }
} />
Gugelhupf
</label>
</div>
{#if defaultForm?.shape === 'round'}
<div class="form-size-inputs">
<label>Durchmesser: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
</div>
{:else if defaultForm?.shape === 'rectangular'}
<div class="form-size-inputs">
<label>Breite: <input type="number" min="1" step="1" bind:value={defaultForm.width} /> cm</label>
<label>Länge: <input type="number" min="1" step="1" bind:value={defaultForm.length} /> cm</label>
</div>
{:else if defaultForm?.shape === 'gugelhupf'}
<div class="form-size-inputs">
<label>Aussen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
<label>Innen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.innerDiameter} /> cm</label>
</div>
{/if}
</div>
<div class="title_container">
<div class="title">
<h4>Eine etwas längere Beschreibung:</h4>
<p bind:innerText={preamble} contenteditable></p>
<input type="hidden" name="preamble" value={preamble} />
<div class="tags">
<h4>Saison:</h4>
<SeasonSelect bind:ranges={season_local} /> <SeasonSelect bind:ranges={season_local} />
</div> </div>
</div>
</div>
<div class="list_wrapper"> <h2 class="section-label">Einleitung</h2>
<div> <p
<CreateIngredientList bind:ingredients /> class="preamble"
</div> contenteditable="plaintext-only"
<div> bind:innerText={preamble}
<CreateStepList bind:instructions bind:add_info /> data-placeholder="Eine etwas längere Einleitung für dieses Rezept…"
</div> aria-label="Einleitung"
</div> ></p>
{/snippet}
<div class="addendum_wrapper"> <div class="below-hero">
<h3>Nachtrag:</h3> <div class="meta-row">
<div class="addendum" bind:innerText={addendum} contenteditable></div> <label class="url-field">
<input type="hidden" name="addendum" value={addendum} /> <span>URL-Kurzname</span>
</div> <input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
</label>
<div class="toggle-field">
<Toggle bind:checked={isBaseRecipe} label="Als Basisrezept markieren" />
</div>
</div>
{#if !showTranslationWorkflow} <div class="form-size-section">
<div class="submit_buttons"> <div class="form-size-head">
<button <span class="form-size-title">Backform (Standard)</span>
type="button" </div>
class="action_button" <div class="form-size-body">
onclick={prepareSubmit} <div class="form-shape-row" role="radiogroup" aria-label="Backform">
disabled={submitting} <button
> type="button"
<p>Weiter zur Übersetzung</p> role="radio"
<Check fill="white" width="2rem" height="2rem" /> aria-checked={!defaultForm}
</button> aria-label="Keine"
title="Keine"
class="shape-tile"
onclick={() => { defaultForm = null; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"/>
<path d="m6 6 12 12"/>
</svg>
</button>
<button
type="button"
role="radio"
aria-checked={defaultForm?.shape === 'round'}
aria-label="Rund"
title="Rund"
class="shape-tile"
onclick={() => { defaultForm = { shape: 'round', diameter: defaultForm?.diameter || 26 }; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"/>
</svg>
</button>
<button
type="button"
role="radio"
aria-checked={defaultForm?.shape === 'rectangular'}
aria-label="Rechteckig"
title="Rechteckig"
class="shape-tile"
onclick={() => { defaultForm = { shape: 'rectangular', width: defaultForm?.width || 20, length: defaultForm?.length || 30 }; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<rect x="3" y="6" width="18" height="12" rx="1.5"/>
</svg>
</button>
<button
type="button"
role="radio"
aria-checked={defaultForm?.shape === 'gugelhupf'}
aria-label="Gugelhupf"
title="Gugelhupf"
class="shape-tile"
onclick={() => { defaultForm = { shape: 'gugelhupf', diameter: defaultForm?.diameter || 24, innerDiameter: defaultForm?.innerDiameter || 8 }; }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
<circle cx="12" cy="12" r="8.5"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
{#if defaultForm?.shape === 'round'}
<div class="form-size-inputs">
<label class="input-wrap">
<span class="input-label">Durchmesser</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.diameter} />
<span class="input-suffix">cm</span>
</span>
</label>
</div>
{:else if defaultForm?.shape === 'rectangular'}
<div class="form-size-inputs">
<label class="input-wrap">
<span class="input-label">Breite</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.width} />
<span class="input-suffix">cm</span>
</span>
</label>
<label class="input-wrap">
<span class="input-label">Länge</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.length} />
<span class="input-suffix">cm</span>
</span>
</label>
</div>
{:else if defaultForm?.shape === 'gugelhupf'}
<div class="form-size-inputs">
<label class="input-wrap">
<span class="input-label">Aussen-Ø</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.diameter} />
<span class="input-suffix">cm</span>
</span>
</label>
<label class="input-wrap">
<span class="input-label">Innen-Ø</span>
<span class="input-box">
<input type="number" min="1" step="1" bind:value={defaultForm.innerDiameter} />
<span class="input-suffix">cm</span>
</span>
</label>
</div>
{/if}
</div>
</div>
<div class="list_wrapper">
<div>
<CreateIngredientList bind:ingredients />
</div>
<div>
<CreateStepList bind:instructions bind:add_info />
</div>
</div>
<div class="addendum_wrapper">
<h3>Nachtrag</h3>
<div
class="addendum"
contenteditable="plaintext-only"
bind:innerText={addendum}
data-placeholder="Optionaler Nachtrag…"
aria-label="Nachtrag"
></div>
<input type="hidden" name="addendum" value={addendum} />
</div>
{#if !showTranslationWorkflow}
<div class="translation-section-trigger">
<h3>Übersetzung</h3>
<div class="section-actions">
<button type="button" class="section-btn" onclick={openTranslation} disabled={submitting}>
Übersetzen & erstellen
</button>
</div>
</div>
{/if}
</div> </div>
{/if} </EditTitleImgParallax>
</form> </form>
<SaveFab type="button" onclick={saveRecipe} disabled={submitting} label="Rezept erstellen" />
{#if showTranslationWorkflow} {#if showTranslationWorkflow}
<div id="translation-section"> <div id="translation-section">
<TranslationApproval <TranslationApproval
+18 -11
View File
@@ -1,4 +1,5 @@
import path from 'path'; import path from 'path';
import { writeFile } from 'fs/promises';
import sharp from 'sharp'; import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash'; import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation'; import { validateImageFile } from '$utils/imageValidation';
@@ -132,21 +133,27 @@ export async function processAndSaveRecipeImage(
unhashed: unhashedFilename unhashed: unhashedFilename
}); });
// Process image with Sharp - convert to WebP format // Full size: the client photo editor already crops, scales and encodes WebP at
// Save full size - both hashed and unhashed versions // the user's chosen quality. Store that byte-for-byte so the on-disk file matches
console.log('[ImageProcessing] Converting to WebP and generating full size...'); // the size the user saw in the editor — re-encoding through sharp would silently
const fullBuffer = await sharp(buffer) // re-compress and discard their quality/size choice.
.toFormat('webp') // Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before.
.webp({ quality: 90 }) // High quality for full size
.toBuffer();
console.log('[ImageProcessing] Full size buffer created, size:', fullBuffer.length, 'bytes');
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename); const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename);
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename); const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename);
let fullBuffer: Buffer;
if (file.type === 'image/webp') {
console.log('[ImageProcessing] Client WebP detected — storing full size as-is (passthrough)');
fullBuffer = buffer;
} else {
console.log('[ImageProcessing] Non-WebP upload — re-encoding full size to WebP q90...');
fullBuffer = await sharp(buffer).toFormat('webp').webp({ quality: 90 }).toBuffer();
}
console.log('[ImageProcessing] Full size buffer ready, size:', fullBuffer.length, 'bytes');
console.log('[ImageProcessing] Saving full size to:', { fullHashedPath, fullUnhashedPath }); console.log('[ImageProcessing] Saving full size to:', { fullHashedPath, fullUnhashedPath });
await sharp(fullBuffer).toFile(fullHashedPath); await writeFile(fullHashedPath, fullBuffer);
await sharp(fullBuffer).toFile(fullUnhashedPath); await writeFile(fullUnhashedPath, fullBuffer);
console.log('[ImageProcessing] Full size images saved'); console.log('[ImageProcessing] Full size images saved');
// Save thumbnail (800px width) - both hashed and unhashed versions // Save thumbnail (800px width) - both hashed and unhashed versions