feat(ui): /new/from-photo Page mit File-Picker, Lade- und Fehler-States

This commit is contained in:
hsiegeln
2026-04-21 10:47:33 +02:00
parent bc42f35f8c
commit 47e91de0a1
3 changed files with 260 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ export function resolveStrategy(req: RequestShape): CacheStrategy {
if (
path === '/api/recipes/import' ||
path === '/api/recipes/preview' ||
path === '/api/recipes/extract-from-photo' ||
path.startsWith('/api/recipes/search/web')
) {
return 'network-only';

View File

@@ -0,0 +1,12 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const load: PageServerLoad = async () => {
if (!env.GEMINI_API_KEY) {
error(503, {
message: 'Foto-Import ist nicht konfiguriert (GEMINI_API_KEY fehlt).'
});
}
return {};
};

View File

@@ -0,0 +1,247 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
Camera,
Loader2,
Wand2,
AlertTriangle,
RotateCw,
FilePlus,
X
} from 'lucide-svelte';
import RecipeEditor from '$lib/components/RecipeEditor.svelte';
import { PhotoUploadStore } from '$lib/client/photo-upload.svelte';
import { alertAction } from '$lib/client/confirm.svelte';
import { network } from '$lib/client/network.svelte';
import type { Recipe, Ingredient, Step } from '$lib/types';
const store = new PhotoUploadStore();
let saving = $state(false);
let fileInput = $state<HTMLInputElement | null>(null);
function onPick(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) void store.upload(file);
}
type SavePatch = {
title: string;
description: string | null;
servings_default: number | null;
prep_time_min: number | null;
cook_time_min: number | null;
total_time_min: number | null;
ingredients: Ingredient[];
steps: Step[];
};
async function onSave(patch: SavePatch) {
if (!store.recipe) return;
saving = true;
try {
const body = {
title: patch.title,
description: patch.description,
servings_default: patch.servings_default,
servings_unit: store.recipe.servings_unit,
prep_time_min: patch.prep_time_min,
cook_time_min: patch.cook_time_min,
total_time_min: patch.total_time_min,
ingredients: patch.ingredients,
steps: patch.steps
};
const res = await fetch('/api/recipes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
await alertAction({
title: 'Speichern fehlgeschlagen',
message: err.message ?? `HTTP ${res.status}`
});
return;
}
const { id } = await res.json();
await goto(`/recipes/${id}`);
} finally {
saving = false;
}
}
function onCancel() {
history.back();
}
</script>
<svelte:head><title>Rezept aus Foto — Kochwas</title></svelte:head>
{#if store.status === 'idle'}
<section class="picker">
<Camera size={48} strokeWidth={1.5} />
<h1>Rezept aus Foto</h1>
<p class="hint">
Fotografiere ein gedrucktes oder handgeschriebenes Rezept. Eine Seite,
scharf, gut ausgeleuchtet.
</p>
<button
type="button"
class="btn primary"
onclick={() => fileInput?.click()}
disabled={!network.online}
>
<Camera size={18} strokeWidth={2} />
<span>Foto wählen oder aufnehmen</span>
</button>
<input
bind:this={fileInput}
type="file"
accept="image/*"
capture="environment"
hidden
onchange={onPick}
/>
{#if !network.online}
<p class="offline">Offline — diese Funktion braucht Internet.</p>
{/if}
</section>
{:else if store.status === 'loading'}
<section class="state" aria-live="polite">
<div class="spin"><Loader2 size={48} /></div>
<p>Lese das Rezept…</p>
<button type="button" class="btn ghost" onclick={() => store.abort()}>
<X size={18} /><span>Abbrechen</span>
</button>
</section>
{:else if store.status === 'error'}
{#if store.errorCode === 'NO_RECIPE_IN_IMAGE'}
<section class="state yellow" role="alert">
<AlertTriangle size={40} />
<h2>Kein Rezept im Bild</h2>
<p>Ich konnte auf dem Foto kein Rezept erkennen.</p>
<div class="row">
<button
type="button"
class="btn primary"
onclick={() => {
store.reset();
fileInput?.click();
}}
>
<Camera size={18} /><span>Anderes Foto</span>
</button>
<a class="btn ghost" href="/">
<FilePlus size={18} /><span>Startseite</span>
</a>
</div>
</section>
{:else}
<section class="state red" role="alert">
<AlertTriangle size={40} />
<h2>Fehler</h2>
<p>{store.errorMessage ?? 'Unbekannter Fehler.'}</p>
<div class="row">
<button type="button" class="btn primary" onclick={() => store.retry()}>
<RotateCw size={18} /><span>Nochmal versuchen</span>
</button>
<button type="button" class="btn ghost" onclick={() => store.reset()}>
<Camera size={18} /><span>Anderes Foto</span>
</button>
</div>
</section>
{/if}
{:else if store.status === 'success' && store.recipe}
<div class="banner">
<Wand2 size={18} />
<span>Aus Foto erstellt — bitte prüfen und ggf. korrigieren.</span>
</div>
<RecipeEditor
recipe={store.recipe as Recipe}
{saving}
onsave={onSave}
oncancel={onCancel}
/>
{/if}
<style>
.picker,
.state {
text-align: center;
padding: 3rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.hint {
color: #666;
max-width: 400px;
line-height: 1.45;
}
.btn {
padding: 0.8rem 1.1rem;
min-height: 48px;
border-radius: 10px;
cursor: pointer;
font-size: 1rem;
border: 0;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn.primary {
background: #2b6a3d;
color: white;
}
.btn.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn.ghost {
background: white;
color: #444;
border: 1px solid #cfd9d1;
}
.row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.state.yellow {
background: #fff6d7;
border: 1px solid #e6d48a;
border-radius: 12px;
}
.state.red {
background: #fde4e4;
border: 1px solid #e6a0a0;
border-radius: 12px;
}
.banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1rem;
background: #eef8ef;
border: 1px solid #b7d9c0;
border-radius: 10px;
margin: 0.75rem 0 1rem;
color: #2b6a3d;
}
.spin {
animation: spin 1s linear infinite;
display: inline-flex;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.offline {
color: #a05b00;
font-size: 0.9rem;
}
</style>