feat(register): Rezept-hinzufügen-Dropdown mit URL-Import + Manuell
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s

Der bisherige immer sichtbare URL-Importbalken ist durch einen
"Rezept hinzufügen"-Button rechts im Register-Head ersetzt. Klick
öffnet ein kleines Dropdown mit zwei Optionen:

  • Von URL importieren — öffnet einen Modal-Dialog zur URL-Eingabe
    und leitet wie bisher nach /preview weiter.
  • Leeres Rezept — POST /api/recipes/blank, Weiterleitung nach
    /recipes/{id}?edit=1; die Detailseite erkennt den Param und
    startet direkt im Editor, entfernt ihn nach Aktivierung wieder
    aus der URL.

Der neue Blank-Endpoint legt ein Rezept mit Platzhalter-Titel
"Neues Rezept", Portions-Default 4 und leeren Listen an. Der User
füllt direkt im Edit-Modus aus und speichert wie gewohnt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-18 14:40:57 +02:00
parent a10ebefb75
commit 60d0cd7659
3 changed files with 319 additions and 50 deletions

View File

@@ -0,0 +1,30 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { insertRecipe } from '$lib/server/recipes/repository';
// Legt ein leeres Rezept an und gibt die ID zurück. Der Client leitet
// danach nach /recipes/{id}?edit=1 um, damit der Editor sofort offen ist.
// Titel "Neues Rezept" ist ein Platzhalter — der User überschreibt ihn
// beim ersten Speichern.
export const POST: RequestHandler = async () => {
const id = insertRecipe(getDb(), {
id: null,
title: 'Neues Rezept',
description: null,
source_url: null,
source_domain: null,
image_path: null,
servings_default: 4,
servings_unit: null,
prep_time_min: null,
cook_time_min: null,
total_time_min: null,
cuisine: null,
category: null,
ingredients: [],
steps: [],
tags: []
});
return json({ id });
};

View File

@@ -1,20 +1,85 @@
<script lang="ts"> <script lang="ts">
import { CookingPot, Link } from 'lucide-svelte'; import { onMount, tick } from 'svelte';
import { CookingPot, Link, Plus, ChevronDown, Pencil } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { alertAction } from '$lib/client/confirm.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let filter = $state(''); let filter = $state('');
let importUrl = $state(''); let importUrl = $state('');
let menuOpen = $state(false);
let importOpen = $state(false);
let creatingBlank = $state(false);
let menuWrap: HTMLElement | undefined = $state();
let importInput: HTMLInputElement | undefined = $state();
function toggleMenu() {
menuOpen = !menuOpen;
}
async function openImport() {
menuOpen = false;
importOpen = true;
await tick();
importInput?.focus();
}
function closeImport() {
importOpen = false;
importUrl = '';
}
function submitImport(e: SubmitEvent) { function submitImport(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
const url = importUrl.trim(); const url = importUrl.trim();
if (!url) return; if (!url) return;
importOpen = false;
goto(`/preview?url=${encodeURIComponent(url)}`); goto(`/preview?url=${encodeURIComponent(url)}`);
} }
async function createBlank() {
if (creatingBlank) return;
menuOpen = false;
creatingBlank = true;
try {
const res = await fetch('/api/recipes/blank', { method: 'POST' });
if (!res.ok) {
await alertAction({
title: 'Anlegen fehlgeschlagen',
message: `HTTP ${res.status}`
});
return;
}
const body = await res.json();
goto(`/recipes/${body.id}?edit=1`);
} finally {
creatingBlank = false;
}
}
function onDocClick(e: MouseEvent) {
if (!menuOpen) return;
if (menuWrap && !menuWrap.contains(e.target as Node)) menuOpen = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (importOpen) closeImport();
else if (menuOpen) menuOpen = false;
}
}
onMount(() => {
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onKey);
};
});
// Umlaute und Diakritika auf Basis-Buchstaben normalisieren, damit // Umlaute und Diakritika auf Basis-Buchstaben normalisieren, damit
// "apfel" auch "Äpfel" findet und "A/Ä/O/Ö/U/Ü" im gleichen Section-Header landen. // "apfel" auch "Äpfel" findet und "A/Ä/O/Ö/U/Ü" im gleichen Section-Header landen.
function normalize(s: string): string { function normalize(s: string): string {
@@ -66,22 +131,86 @@
</script> </script>
<header class="head"> <header class="head">
<div class="head-top">
<div class="head-titles">
<h1>Register</h1> <h1>Register</h1>
<p class="sub">{data.recipes.length} Rezepte insgesamt</p> <p class="sub">{data.recipes.length} Rezepte insgesamt</p>
</div>
<div class="add-menu" bind:this={menuWrap}>
<button
type="button"
class="add-btn"
onclick={toggleMenu}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<Plus size={16} strokeWidth={2.2} />
<span>Rezept hinzufügen</span>
<ChevronDown size={14} strokeWidth={2.2} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<button type="button" role="menuitem" class="menu-item" onclick={openImport}>
<Link size={16} strokeWidth={2} />
<div class="menu-text">
<div class="menu-title">Von URL importieren</div>
<div class="menu-desc">Rezept aus einer Website ziehen</div>
</div>
</button>
<button
type="button"
role="menuitem"
class="menu-item"
onclick={createBlank}
disabled={creatingBlank}
>
<Pencil size={16} strokeWidth={2} />
<div class="menu-text">
<div class="menu-title">Leeres Rezept</div>
<div class="menu-desc">Manuell ausfüllen</div>
</div>
</button>
</div>
{/if}
</div>
</div>
</header> </header>
<form class="import-url" onsubmit={submitImport}> {#if importOpen}
<span class="import-icon" aria-hidden="true"><Link size={16} strokeWidth={2} /></span> <div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) closeImport();
}}
>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="import-title"
tabindex="-1"
>
<h2 id="import-title">Rezept-URL importieren</h2>
<form onsubmit={submitImport}>
<input <input
bind:this={importInput}
type="url" type="url"
bind:value={importUrl} bind:value={importUrl}
placeholder="Neues Rezept von URL importieren …" placeholder="https://…"
aria-label="Rezept-URL importieren" aria-label="Rezept-URL"
required
/> />
<button type="submit" class="import-go" disabled={!importUrl.trim()}> <div class="modal-actions">
Importieren <button type="button" class="btn" onclick={closeImport}>Abbrechen</button>
<button type="submit" class="btn primary" disabled={!importUrl.trim()}>
Weiter
</button> </button>
</form> </div>
</form>
</div>
</div>
{/if}
<div class="filter-wrap"> <div class="filter-wrap">
<input <input
@@ -136,6 +265,16 @@
.head { .head {
padding: 1.25rem 0 0.5rem; padding: 1.25rem 0 0.5rem;
} }
.head-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.head-titles {
min-width: 0;
}
.head h1 { .head h1 {
margin: 0; margin: 0;
font-size: 1.6rem; font-size: 1.6rem;
@@ -146,55 +285,145 @@
color: #666; color: #666;
font-size: 0.9rem; font-size: 0.9rem;
} }
.import-url { .add-menu {
display: flex; position: relative;
align-items: stretch; flex-shrink: 0;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
background: white;
border: 1px solid #cfd9d1;
border-radius: 12px;
padding: 0.25rem 0.25rem 0.25rem 0.75rem;
} }
.import-url:focus-within { .add-btn {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
}
.import-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
color: #6a7670; gap: 0.5rem;
} padding: 0.55rem 0.9rem;
.import-url input {
flex: 1;
border: 0;
background: transparent;
font-size: 0.95rem;
padding: 0.5rem 0.25rem;
min-height: 40px;
min-width: 0;
}
.import-url input:focus {
outline: none;
}
.import-go {
padding: 0 0.9rem;
background: #2b6a3d; background: #2b6a3d;
color: white; color: white;
border: 0; border: 0;
border-radius: 10px; border-radius: 10px;
font-size: 0.9rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
min-height: 40px; min-height: 40px;
flex-shrink: 0;
} }
.import-go:hover:not(:disabled) { .add-btn:hover {
background: #235532; background: #235532;
} }
.import-go:disabled { .menu {
opacity: 0.45; position: absolute;
top: calc(100% + 0.35rem);
right: 0;
min-width: 260px;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 0.3rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.75rem;
background: transparent;
border: 0;
border-radius: 8px;
text-align: left;
cursor: pointer;
font-family: inherit;
color: #1a1a1a;
width: 100%;
}
.menu-item:hover:not(:disabled) {
background: #f4f8f5;
}
.menu-item:disabled {
opacity: 0.55;
cursor: progress;
}
.menu-item :global(svg) {
color: #2b6a3d;
flex-shrink: 0;
}
.menu-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.menu-title {
font-weight: 600;
font-size: 0.95rem;
}
.menu-desc {
color: #888;
font-size: 0.8rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(20, 30, 25, 0.45);
display: grid;
place-items: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: white;
border-radius: 14px;
padding: 1.1rem 1.1rem 1rem;
width: min(440px, 100%);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
}
.modal h2 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
color: #2b6a3d;
}
.modal input {
width: 100%;
padding: 0.7rem 0.85rem;
border: 1px solid #cfd9d1;
border-radius: 10px;
font-size: 1rem;
min-height: 44px;
font-family: inherit;
box-sizing: border-box;
}
.modal input:focus {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.85rem;
}
.modal .btn {
padding: 0.6rem 1rem;
min-height: 42px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
font-family: inherit;
}
.modal .btn:hover:not(:disabled) {
background: #f4f8f5;
}
.modal .btn.primary {
background: #2b6a3d;
color: white;
border: 0;
}
.modal .btn.primary:hover:not(:disabled) {
background: #235532;
}
.modal .btn.primary:disabled {
opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
} }
.filter-wrap { .filter-wrap {

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, tick } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { import {
Heart, Heart,
@@ -315,6 +316,15 @@
} }
onMount(() => { onMount(() => {
// Wenn wir über "Manuell anlegen" hier landen, ist ?edit=1 gesetzt
// und wir starten direkt im Editor. Den Param danach aus der URL
// entfernen, damit Refresh nicht automatisch wieder edit-Mode ist.
if ($page.url.searchParams.get('edit') === '1') {
editMode = true;
const url = new URL(window.location.href);
url.searchParams.delete('edit');
history.replaceState(history.state, '', url.toString());
}
const stored = localStorage.getItem('kochwas.wakeLock'); const stored = localStorage.getItem('kochwas.wakeLock');
if (stored !== null) wakeLockEnabled = stored === '1'; if (stored !== null) wakeLockEnabled = stored === '1';
if (wakeLockEnabled) void acquireWakeLock(); if (wakeLockEnabled) void acquireWakeLock();