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
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:
30
src/routes/api/recipes/blank/+server.ts
Normal file
30
src/routes/api/recipes/blank/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user