All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 30s
Android-Chrome auf Tablet verhaelt sich zickig: mit capture="environment" nur Kamera, ohne capture nur Datei-Picker -- nie beide. Zwei separate Buttons (mit jeweils eigenem Input-Element) machen die Wahl explizit und funktionieren ueberall eindeutig. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
272 lines
6.9 KiB
Svelte
272 lines
6.9 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import {
|
|
Camera,
|
|
ImageUp,
|
|
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 cameraInput = $state<HTMLInputElement | null>(null);
|
|
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>
|
|
<div class="row">
|
|
<button
|
|
type="button"
|
|
class="btn primary"
|
|
onclick={() => cameraInput?.click()}
|
|
disabled={!network.online}
|
|
>
|
|
<Camera size={18} strokeWidth={2} />
|
|
<span>Kamera</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn ghost"
|
|
onclick={() => fileInput?.click()}
|
|
disabled={!network.online}
|
|
>
|
|
<ImageUp size={18} strokeWidth={2} />
|
|
<span>Aus Dateien</span>
|
|
</button>
|
|
</div>
|
|
<!-- Zwei separate Inputs: capture="environment" oeffnet direkt die Kamera,
|
|
das andere zeigt den Datei-/Fotomediathek-Picker. Android-Chrome auf
|
|
Tablet zeigt sonst bei capture="environment" nur die Kamera; ohne
|
|
capture dagegen nur den Datei-Picker. Explizite Wahl ist eindeutig. -->
|
|
<input
|
|
bind:this={cameraInput}
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
hidden
|
|
onchange={onPick}
|
|
/>
|
|
<input
|
|
bind:this={fileInput}
|
|
type="file"
|
|
accept="image/*"
|
|
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>
|