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}
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if loadError}
+
{loadError}
+ {:else if !bitmap}
+
Lade Bild…
+ {:else}
+
+
+
startDrag(e, 'move', 0, 0)}
+ onpointermove={onPointerMove}
+ onpointerup={endDrag}
+ onpointercancel={endDrag}
+ >
+
+
+
+
+ {#each handles as h (h.key)}
+ startDrag(e, h.key, h.hx, h.hy)}
+ >
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
+ {#if outUrl}
+
+
+ {/if}
+
+
+
Auflösung {outW || '—'} × {outH || '—'}
+
+
Dateigrösse
+ {outBlob ? formatBytes(outBlob.size) : '—'}
+
+
+
+
+
+ Seitenverhältnis
+
+ {#each RATIOS as r (r.key)}
+ selectRatio(r.key)}>{r.label}
+ {/each}
+
+
+
+
+ Max. Auflösung
+
+ {#each RES_PRESETS as p (p)}
+ (maxRes = p)}>{p === 0 ? 'Original' : p}
+ {/each}
+
+
+ Eigene Kante
+
+ px
+
+
+
+
+ WebP-Qualität
+
+
+ {quality}
+
+
+
+
Zuschnitt zurücksetzen
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
-
- console.log(seasonRanges)}>PRINTOUT season
-
-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 {
+
-
-
-
-
-
-
-
-
-
-
Eine etwas längere Beschreibung:
-
-
-
-
-
-
+
Einleitung
+
+ {/snippet}
-
+
+
- {#if !showTranslationWorkflow}
-
- {/if}
+
+
+
{#if showTranslationWorkflow}