diff --git a/CODEMAP.md b/CODEMAP.md index c815b910..38e528de 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -173,7 +173,6 @@ Generated: 2025-11-18 - `EditButton.svelte` - Edit button (floating) - `FavoriteButton.svelte` - Toggle favorite - `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category -- `CardAdd.svelte` - Add recipe card placeholder - `FormSection.svelte` - Styled form section wrapper - `Header.svelte` - Page header - `UserHeader.svelte` - User-specific header @@ -190,7 +189,6 @@ Generated: 2025-11-18 #### Recipe-Specific Components - `Recipes.svelte` - Recipe list display -- `RecipeEditor.svelte` - Recipe editing form - `RecipeNote.svelte` - Recipe notes display - `EditRecipe.svelte` - Edit recipe modal - `EditRecipeNote.svelte` - Edit recipe notes diff --git a/package.json b/package.json index 2feb5403..21b8d06f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.94.1", + "version": "1.95.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/recipes/CardAdd.svelte b/src/lib/components/recipes/CardAdd.svelte deleted file mode 100644 index b30d15ff..00000000 --- a/src/lib/components/recipes/CardAdd.svelte +++ /dev/null @@ -1,434 +0,0 @@ - - - - -
- - - {#if image_preview_url} - - - {/if} -
- {#if image_preview_url} - - {/if} - - - -
- -
- -
- -

-
-
- {#each card_data.tags as tag (tag)} - -
remove_on_enter(event, tag)} onclick={() => remove_from_tags(tag)} aria-label="Tag {tag} entfernen">{tag}
- {/each} -
+
-
-
- -
diff --git a/src/lib/components/recipes/EditTitleImgParallax.svelte b/src/lib/components/recipes/EditTitleImgParallax.svelte index da92603c..38a8f9b8 100644 --- a/src/lib/components/recipes/EditTitleImgParallax.svelte +++ b/src/lib/components/recipes/EditTitleImgParallax.svelte @@ -1,5 +1,6 @@ + + + + + + diff --git a/src/lib/components/recipes/RecipeEditor.svelte b/src/lib/components/recipes/RecipeEditor.svelte deleted file mode 100644 index c74a931f..00000000 --- a/src/lib/components/recipes/RecipeEditor.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - -

Zutaten

- -

Zubereitung

- - diff --git a/src/lib/js/imageEdit.ts b/src/lib/js/imageEdit.ts new file mode 100644 index 00000000..c27b1943 --- /dev/null +++ b/src/lib/js/imageEdit.ts @@ -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 ``; 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 { + 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` (1–100). Returns the encoded Blob; read + * `.size` for the final byte count. + */ +export async function renderToBlob( + bitmap: ImageBitmap, + crop: CropRect, + maxRes: number, + quality: number +): Promise { + 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((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`; +} diff --git a/src/routes/[recipeLang=recipeLang]/add/+page.svelte b/src/routes/[recipeLang=recipeLang]/add/+page.svelte index f2e6decd..bcd9d4a4 100644 --- a/src/routes/[recipeLang=recipeLang]/add/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/add/+page.svelte @@ -2,15 +2,14 @@ import { enhance } from '$app/forms'; import { tick } from 'svelte'; 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 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 CreateStepList from '$lib/components/recipes/CreateStepList.svelte'; import { toast } from '$lib/js/toast.svelte'; import Toggle from '$lib/components/Toggle.svelte'; - import '$lib/css/action_button.css'; let { data, form }: { data: PageData; form: ActionData } = $props(); @@ -87,20 +86,30 @@ defaultForm, }); - // Show translation workflow before submission - function prepareSubmit() { - // Client-side validation + function validate(): boolean { if (!short_name.trim()) { toast.error('Bitte geben Sie einen Kurznamen ein'); - return; + return false; } if (!card_data.name) { 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; - // Scroll to translation section setTimeout(() => { document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' }); }, 100); @@ -147,141 +156,322 @@ @@ -289,8 +479,6 @@ button.action_button { -

Rezept erstellen

- {#if form?.error}
Fehler: {form.error} @@ -330,16 +518,6 @@ button.action_button { })} /> {/if} - - -

Kurzname (für URL):

- - @@ -348,99 +526,187 @@ button.action_button { + -
- -
- - -
-

Backform (Standard):

-
- - - - -
- {#if defaultForm?.shape === 'round'} -
- -
- {:else if defaultForm?.shape === 'rectangular'} -
- - -
- {:else if defaultForm?.shape === 'gugelhupf'} -
- - -
- {/if} -
- -
-
-

Eine etwas längere Beschreibung:

-

- - -
-

Saison:

+ + {#snippet titleExtras()} + +
-
-
-
-
- -
-
- -
-
+ +

+ {/snippet} -
-

Nachtrag:

-
- -
+
+
+ +
+ +
+
- {#if !showTranslationWorkflow} -
- +
+
+ Backform (Standard) +
+
+
+ + + + +
+ + {#if defaultForm?.shape === 'round'} +
+ +
+ {:else if defaultForm?.shape === 'rectangular'} +
+ + +
+ {:else if defaultForm?.shape === 'gugelhupf'} +
+ + +
+ {/if} +
+
+ +
+
+ +
+
+ +
+
+ +
+

Nachtrag

+
+ +
+ + {#if !showTranslationWorkflow} +
+

Übersetzung

+
+ +
+
+ {/if}
- {/if} + + + {#if showTranslationWorkflow}