refactor: reorganize components into domain subfolders and replace relative imports

Move components from flat src/lib/components/ into recipes/, faith/, and
cospend/ subdirectories. Replace ~144 relative imports across API routes
and lib files with $models, $utils, $types, and $lib aliases. Add $types
alias to svelte.config.js. Remove unused EditRecipe.svelte.
This commit is contained in:
2026-02-10 21:46:16 +01:00
parent 896a99382d
commit 9e7ab0b16f
149 changed files with 286 additions and 611 deletions
@@ -0,0 +1,274 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { do_on_key } from '$lib/components/recipes/do_on_key.js'
import Check from '$lib/assets/icons/Check.svelte'
let {
type = 'ingredients' as 'ingredients' | 'instructions',
onSelect,
open = $bindable(false)
}: {
type?: 'ingredients' | 'instructions',
onSelect: (recipe: any, options: any) => void,
open?: boolean
} = $props();
// Unique dialog ID based on type to prevent conflicts when both are on the same page
const dialogId = `base-recipe-selector-modal-${type}`;
let baseRecipes: any[] = $state([]);
let selectedRecipe: any = $state(null);
let options = $state({
includeIngredients: false,
includeInstructions: false,
showLabel: true,
labelOverride: '',
baseMultiplier: 1
});
// Reset options whenever type or modal state changes
$effect(() => {
if (open || type) {
options.includeIngredients = type === 'ingredients';
options.includeInstructions = type === 'instructions';
}
});
onMount(async () => {
const res = await fetch('/api/rezepte/base-recipes');
baseRecipes = await res.json();
});
function handleInsert() {
if (selectedRecipe) {
onSelect(selectedRecipe, options);
// Reset modal
selectedRecipe = null;
options.labelOverride = '';
options.showLabel = true;
options.baseMultiplier = 1;
closeModal();
}
}
function closeModal() {
open = false;
if (browser) {
const modal = document.querySelector(`#${dialogId}`) as HTMLDialogElement;
if (modal) {
modal.close();
}
}
}
function openModal() {
if (browser) {
const modal = document.querySelector(`#${dialogId}`) as HTMLDialogElement;
if (modal) {
modal.showModal();
}
}
}
$effect(() => {
if (browser) {
if (open) {
setTimeout(openModal, 0);
} else {
closeModal();
}
}
});
</script>
<style>
dialog {
box-sizing: content-box;
width: 100%;
height: 100%;
background-color: transparent;
border: unset;
margin: 0;
transition: 500ms;
}
dialog[open]::backdrop {
animation: show 200ms ease forwards;
}
@keyframes show {
from {
backdrop-filter: blur(0px);
}
to {
backdrop-filter: blur(10px);
}
}
dialog h2 {
font-size: 3rem;
font-family: sans-serif;
color: white;
text-align: center;
margin-top: 30vh;
margin-top: 30dvh;
filter: drop-shadow(0 0 0.4em black)
drop-shadow(0 0 1em black);
}
.selector-content {
box-sizing: border-box;
margin-inline: auto;
margin-top: 2rem;
max-width: 600px;
padding: 2rem;
border-radius: 20px;
background-color: var(--blue);
color: white;
box-shadow: 0 0 1em 0.2em rgba(0,0,0,0.3);
}
.selector-content label {
display: block;
margin-block: 1rem;
font-size: 1.1rem;
}
.selector-content select,
.selector-content input[type="text"],
.selector-content input[type="number"] {
width: 100%;
padding: 0.5em 1em;
margin-top: 0.5em;
border-radius: 1000px;
border: 2px solid var(--nord4);
background-color: white;
color: var(--nord0);
font-size: 1rem;
transition: 100ms;
}
.selector-content select:hover,
.selector-content select:focus,
.selector-content input[type="text"]:hover,
.selector-content input[type="text"]:focus,
.selector-content input[type="number"]:hover,
.selector-content input[type="number"]:focus {
border-color: var(--nord9);
transform: scale(1.02, 1.02);
}
.selector-content input[type="checkbox"] {
width: 1.2em;
height: 1.2em;
margin-right: 0.5em;
vertical-align: middle;
}
.button-group {
display: flex;
gap: 1rem;
margin-top: 2rem;
justify-content: center;
}
.button-group button {
padding: 0.75em 2em;
font-size: 1.1rem;
border-radius: 1000px;
border: none;
cursor: pointer;
transition: 200ms;
font-weight: bold;
}
.button-insert {
background-color: var(--nord14);
color: var(--nord0);
}
.button-cancel {
background-color: var(--nord3);
color: white;
}
.button-group button:hover {
transform: scale(1.1, 1.1);
box-shadow: 0 0 1em 0.3em rgba(0,0,0,0.3);
}
@media (prefers-color-scheme: dark) {
.selector-content {
background-color: var(--nord1);
}
}
</style>
<dialog id={dialogId}>
<h2>Basisrezept einfügen</h2>
<div class="selector-content">
<label>
Basisrezept auswählen:
<select bind:value={selectedRecipe}>
<option value={null}>-- Auswählen --</option>
{#each baseRecipes as recipe}
<option value={recipe}>{recipe.icon} {recipe.name}</option>
{/each}
</select>
</label>
{#if type === 'ingredients'}
<label>
<input type="checkbox" bind:checked={options.includeIngredients} />
Zutaten einbeziehen
</label>
{/if}
{#if type === 'instructions'}
<label>
<input type="checkbox" bind:checked={options.includeInstructions} />
Zubereitungsschritte einbeziehen
</label>
{/if}
<label>
<input type="checkbox" bind:checked={options.showLabel} />
Rezeptname als Überschrift anzeigen
</label>
{#if options.showLabel}
<label>
Eigene Überschrift (optional):
<input
type="text"
bind:value={options.labelOverride}
placeholder={selectedRecipe?.name || 'Überschrift eingeben...'}
onkeydown={(event) => do_on_key(event, 'Enter', false, handleInsert)}
/>
</label>
{/if}
<label>
Mengenfaktor (Multiplikator):
<input
type="number"
bind:value={options.baseMultiplier}
min="0"
step="any"
placeholder="1"
onkeydown={(event) => do_on_key(event, 'Enter', false, handleInsert)}
/>
</label>
<div class="button-group">
<button class="button-insert" onclick={handleInsert} disabled={!selectedRecipe}>
Einfügen
</button>
<button class="button-cancel" onclick={closeModal}>
Abbrechen
</button>
</div>
</div>
</dialog>
+272
View File
@@ -0,0 +1,272 @@
<script lang="ts">
import "$lib/css/nordtheme.css";
import "$lib/css/shake.css";
import "$lib/css/icon.css";
import { onMount } from "svelte";
let {
recipe,
current_month: currentMonthProp = 0,
icon_override = false,
search = true,
do_margin_right = false,
isFavorite = false,
showFavoriteIndicator = false,
loading_strat = "lazy",
routePrefix = '/rezepte',
translationStatus = undefined
} = $props();
// Make current_month reactive based on icon_override
let current_month = $derived(icon_override ? recipe.season[0] : currentMonthProp);
let isloaded = $state(false);
onMount(() => {
isloaded = document.querySelector("img")?.complete ? true : false
});
// Use mediapath from images array (includes hash for cache busting)
// Fallback to short_name.webp for backward compatibility
const img_name = $derived(
recipe.images?.[0]?.mediapath ||
`${recipe.germanShortName || recipe.short_name}.webp`
);
// Get alt text from images array
const img_alt = $derived(
recipe.images?.[0]?.alt || recipe.name
);
</script>
<style>
.card-main-link {
position: absolute;
inset: 0;
z-index: 1;
text-decoration: none;
}
.card-main-link .visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
.card{
--card-width: 300px;
position: relative;
flex-shrink: 0;
transition: var(--transition-normal);
text-decoration: none;
box-sizing: border-box;
font-family: sans-serif;
cursor: pointer;
height: 525px;
width: 300px;
border-radius: var(--radius-card);
background-size: contain;
display: inline-flex;
flex-direction: column;
justify-content: end;
background-color: var(--blue);
box-shadow: var(--shadow-lg);
color: inherit;
}
/* Position/size overrides for global g-icon-badge */
.icon{
position: absolute;
top: -25px;
right: -25px;
width: 50px;
height: 50px;
font-size: 1.5em;
background-color: var(--nord0);
color: white;
z-index: 5;
}
.image{
width: 300px;
height: 255px;
object-fit: cover;
transition: var(--transition-normal);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.blur{
filter: blur(10px);
}
.backdrop_blur{
backdrop-filter: blur(10px);
}
.card-image{
width: 300px;
height: 255px;
position: absolute;
top: 0;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
overflow: hidden;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.card:hover,
.card:focus-within{
transform: scale(1.02,1.02);
background-color: var(--red);
box-shadow: var(--shadow-hover);
}
.card:focus{
scale: 0.95 0.95;
}
.card_title {
position: absolute;
padding-top: 0.5em;
height: 262.5px;
width: 300px;
top: 262.5px;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: var(--transition-fast);
}
.name{
font-size: 2em;
color: white;
padding-inline: 0.5em;
padding-block: 0.2em;
}
.description{
padding-inline: 1em;
color: var(--nord4);
}
.tags{
display: flex;
flex-wrap: wrap;
overflow: hidden;
column-gap: 0.25em;
padding-inline: 0.5em;
padding-top: 0.25em;
margin-bottom:0.5em;
flex-grow: 0;
}
/* Overrides for Card tags - uses g-tag base, with Card-specific adjustments */
.tag{
background-color: var(--nord4);
padding-inline: 1em;
line-height: 1.5em;
margin-bottom: 0.5em;
position: relative;
color: black;
z-index: 2;
}
/* Position overrides for Card category */
.card_title .category{
position: absolute;
font-size: 1.5rem;
top: -0.8em;
left: -0.5em;
padding-inline: 1em;
z-index: 2;
}
.card_title .category:hover,
.card_title .category:focus-within {
transform: scale(1.05);
}
.favorite-indicator{
position: absolute;
font-size: 2rem;
top: 0.1em;
left: 0.1em;
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8));
}
.translation-badge{
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
z-index: 3;
color: var(--nord0);
}
.translation-badge.none{
background-color: var(--nord14);
}
.translation-badge.pending{
background-color: var(--nord13);
}
.translation-badge.needs_update{
background-color: var(--nord12);
}
/* Override hover color for Card icon */
.icon:hover,
.icon:focus-visible {
background-color: var(--nord3);
}
.card:hover .icon,
.card:focus-visible .icon
{
animation: shake 0.6s;
}
.margin_right{
margin-right: 2em;
}
</style>
<div class="card" class:search_me={search} class:margin_right={do_margin_right} data-tags="[{recipe.tags}]">
<a href="{routePrefix}/{recipe.short_name}" class="card-main-link" aria-label="View recipe: {recipe.name}">
<span class="visually-hidden">View recipe: {recipe.name}</span>
</a>
<div class="card-image" style="background-image:url(https://bocken.org/static/rezepte/placeholder/{img_name})">
<noscript>
<img class="image backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{img_alt}"/>
</noscript>
<img class="image backdrop_blur" class:blur={!isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{img_alt}" onload={() => isloaded=true}/>
</div>
{#if showFavoriteIndicator && isFavorite}
<div class="favorite-indicator">❤️</div>
{/if}
{#if translationStatus !== undefined}
<div class="translation-badge {translationStatus || 'none'}">
{#if translationStatus === 'pending'}
Freigabe ausstehend
{:else if translationStatus === 'needs_update'}
Aktualisierung erforderlich
{:else}
Keine Übersetzung
{/if}
</div>
{/if}
{#if icon_override || recipe.season.includes(current_month)}
<a href="{routePrefix}/icon/{recipe.icon}" class="icon g-icon-badge">{recipe.icon}</a>
{/if}
<div class="card_title">
<a href="{routePrefix}/category/{recipe.category}" class="category g-pill g-btn-dark">{recipe.category}</a>
<div>
<div class=name>{@html recipe.name}</div>
<div class=description>{@html recipe.description}</div>
</div>
<div class=tags>
{#each recipe.tags as tag}
<a href="{routePrefix}/tag/{tag}" class="tag g-pill g-interactive">{tag}</a>
{/each}
</div>
</div>
</div>
+434
View File
@@ -0,0 +1,434 @@
<script lang="ts">
import Cross from '$lib/assets/icons/Cross.svelte'
import "$lib/css/shake.css"
import "$lib/css/icon.css"
import { onMount } from 'svelte'
let {
card_data = $bindable(),
image_preview_url = $bindable(''),
selected_image_file = $bindable<File | null>(null),
short_name = ''
}: {
card_data: any,
image_preview_url: string,
selected_image_file: File | null,
short_name: string
} = $props();
// Constants for validation
const ALLOWED_MIME_TYPES = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
// Handle file selection via onchange event
function handleFileSelect(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
// Validate MIME type
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
alert('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
input.value = '';
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
alert(`File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`);
input.value = '';
return;
}
// Clean up old preview URL if exists
if (image_preview_url && image_preview_url.startsWith('blob:')) {
URL.revokeObjectURL(image_preview_url);
}
// Create preview and store file
image_preview_url = URL.createObjectURL(file);
selected_image_file = file;
}
// Check if initial image_preview_url redirects to placeholder
onMount(() => {
if (image_preview_url && !image_preview_url.startsWith('blob:')) {
const img = new Image();
img.onload = () => {
// Check if this is the placeholder image (150x150)
if (img.naturalWidth === 150 && img.naturalHeight === 150) {
image_preview_url = ""
}
};
img.onerror = () => {
image_preview_url = ""
};
img.src = image_preview_url;
}
});
// Initialize tags if needed
if (!card_data.tags) {
card_data.tags = []
}
// Tag management
let new_tag = $state("");
// Reference to file input for clearing
let fileInput: HTMLInputElement;
function remove_selected_images() {
if (image_preview_url && image_preview_url.startsWith('blob:')) {
URL.revokeObjectURL(image_preview_url);
}
image_preview_url = "";
selected_image_file = null;
// Reset the file input
if (fileInput) {
fileInput.value = '';
}
}
function add_to_tags() {
if (new_tag && !card_data.tags.includes(new_tag)) {
card_data.tags = [...card_data.tags, new_tag];
}
new_tag = "";
}
function remove_from_tags(tag: string) {
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
}
function add_on_enter(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault();
add_to_tags();
}
}
function remove_on_enter(event: KeyboardEvent, tag: string) {
if (event.key === 'Enter') {
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
}
}
</script>
<style>
.card{
position: relative;
margin-inline: auto;
--card-width: 300px;
text-decoration: none;
position: relative;
box-sizing: border-box;
font-family: sans-serif;
width: var(--card-width);
aspect-ratio: 4/7;
border-radius: 20px;
background-size: contain;
display: flex;
flex-direction: column;
justify-content: end;
transition: 200ms;
background-color: var(--blue);
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.3);
z-index: 0;
}
.img_label{
position :absolute;
z-index: 1;
top: 0;
width: 100%;
height: 100%;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
border-radius: 20px 20px 0 0 ;
transition: 200ms;
}
.img_label_wrapper:hover{
background-color: var(--red);
box-shadow: 0 2em 1em 0.5em rgba(0,0,0,0.3);
transform:scale(1.02, 1.02);
}
.img_label_wrapper{
position: absolute;
height: 50%;
width: 100%;
top:0;
left: 0;
border-radius: 20px 20px 0 0;
transition: 200ms;
}
.img_label_wrapper:hover .delete{
opacity: 100%;
}
.img_label svg{
width: 100px;
height: 100px;
fill: white;
transition: 200ms;
}
.delete{
cursor: pointer;
all: unset;
position: absolute;
top:2rem;
left: 2rem;
opacity: 0%;
z-index: 4;
transition:200ms;
}
.delete:hover{
transform: scale(1.2, 1.2);
}
.upload{
z-index: 1;
}
.img_label:hover .upload{
transform: scale(1.2, 1.2);
z-index: 10;
}
#img_picker{
display: none;
width: 300px;
height: 300px;
position:absolute;
}
input{
all: unset;
}
input::placeholder{
all:unset;
}
.card .icon{
z-index: 3;
box-sizing: border-box;
text-decoration: unset;
text-align:center;
width: 2.6rem;
aspect-ratio: 1/1;
transition: 100ms;
position: absolute;
font-size: 1.5rem;
top:-0.5em;
right:-0.5em;
padding: 0.25em;
background-color: var(--nord6);
border-radius:1000px;
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.6);
}
.card .icon:hover,
.card .icon:focus-visible
{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform:scale(1.2, 1.2)
}
.card:hover,
.card:focus-within{
transform: scale(1.02,1.02);
box-shadow: 0.2em 0.2em 2em 1em rgba(0, 0, 0, 0.3);
}
.card img{
height: 50%;
object-fit: cover;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.card .title {
position: relative;
box-sizing: border-box;
padding-top: 0.5em;
height: 50%;
width: 100% ;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: 100ms;
}
.card .name{
all: unset;
width:100%;
font-size: 2em;
color: white;
padding-inline: 0.5em;
padding-block: 0.2em;
}
.card .name:hover{
color:var(--nord0);
}
.card .description{
box-sizing:border-box;
border: 2px solid var(--nord5);
border-radius: 30px;
padding-inline: 1em;
padding-block: 0.5em;
margin-inline: 1em;
margin-top: 0;
color: var(--nord4);
width: calc(300px - 2em); /*??*/
}
.card .description:hover{
color: var(--nord0);
border: 2px solid var(--nord0);
}
.card .tags{
display: flex;
flex-wrap: wrap-reverse;
overflow: hidden;
column-gap: 0.25em;
padding-inline: 0.5em;
padding-top: 0.25em;
margin-bottom:0.5em;
flex-grow: 0;
}
.card .tag{
cursor: pointer;
text-decoration: unset;
background-color: var(--nord4);
color: var(--nord0);
border-radius: 100px;
padding-inline: 1em;
line-height: 1.5em;
margin-bottom: 0.5em;
transition: 100ms;
box-shadow: 0.2em 0.2em 0.2em 0.05em rgba(0, 0, 0, 0.3);
}
.card .tag:hover,
.card .tag:focus-visible,
.card .tag:focus-within
{
transform: scale(1.04, 1.04);
background-color: var(--nord8);
box-shadow: 0.2em 0.2em 0.2em 0.1em rgba(0, 0, 0, 0.3);
}
.card .title .category{
z-index: 2;
position: absolute;
box-shadow: 0em 0em 1em 0.1em rgba(0, 0, 0, 0.6);
text-decoration: none;
color: var(--nord6);
font-size: 1.5rem;
top: -0.8em;
left: -0.5em;
width: 10rem;
background-color: var(--nord0);
padding-inline: 1em;
border-radius: 1000px;
transition: 100ms;
}
.card .title .category:hover,
.card .title .category:focus-within
{
box-shadow: -0.2em 0.2em 1em 0.1em rgba(0, 0, 0, 0.6);
background-color: var(--nord3);
transform: scale(1.05, 1.05)
}
.card:hover .icon,
.card:focus-visible .icon
{
animation: shake 0.6s
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(-30deg)
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
transform: rotate(30deg)
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1,1);
}
}
.input_wrapper{
position: relative;
padding-left: 3rem;
padding-left: 40rem;
}
.input_wrapper > input{
margin-left: 1ch;
}
.input{
position:absolute;
top: -.1ch;
left: 0.6ch;
font-size: 1.6rem;
}
.tag_input{
width: 12ch;
}
</style>
<div class=card href="" >
<input class=icon placeholder=🥫 bind:value={card_data.icon}/>
{#if image_preview_url}
<!-- svelte-ignore a11y_missing_attribute -->
<img src={image_preview_url} class=img_preview width=300px height=300px />
{/if}
<div class=img_label_wrapper>
{#if image_preview_url}
<button class=delete onclick={remove_selected_images}>
<Cross fill=white style="width:2rem;height:2rem;"></Cross>
</button>
{/if}
<label class=img_label for=img_picker>
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
</label>
</div>
<input type="file" id="img_picker" accept="image/webp,image/jpeg,image/jpg,image/png" onchange={handleFileSelect} bind:this={fileInput}>
<div class=title>
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
<div>
<input class=name placeholder=Name... bind:value={card_data.name}/>
<p contenteditable class=description placeholder=Kurzbeschreibung... bind:innerText={card_data.description}></p>
</div>
<div class=tags>
{#each card_data.tags as tag (tag)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="tag" role="button" tabindex="0" onkeydown={(event) => remove_on_enter(event, tag)} onclick={() => remove_from_tags(tag)} aria-label="Tag {tag} entfernen">{tag}</div>
{/each}
<div class="tag input_wrapper"><span class=input>+</span><input class="tag_input" type="text" onkeydown={add_on_enter} onfocusout={add_to_tags} size="1" bind:value={new_tag} placeholder=Stichwort...></div>
</div>
</div>
</div>
@@ -0,0 +1,239 @@
<script>
import "$lib/css/nordtheme.css";
import TagChip from '$lib/components/recipes/TagChip.svelte';
let {
categories = [],
selected = null,
onChange = () => {},
lang = 'de',
useAndLogic = true
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Category' : 'Kategorie');
const selectLabel = $derived(isEnglish ? 'Select category...' : 'Kategorie auswählen...');
// Convert selected to array for OR mode, keep as single value for AND mode
const selectedArray = $derived(
useAndLogic
? (selected ? [selected] : [])
: (Array.isArray(selected) ? selected : (selected ? [selected] : []))
);
let inputValue = $state('');
let dropdownOpen = $state(false);
// Filter categories based on input
const filteredCategories = $derived(
inputValue.trim() === ''
? categories
: categories.filter(cat =>
cat.toLowerCase().includes(inputValue.toLowerCase())
)
);
function handleInputFocus() {
dropdownOpen = true;
}
function handleInputBlur() {
setTimeout(() => {
dropdownOpen = false;
inputValue = '';
}, 200);
}
function handleCategorySelect(category) {
if (useAndLogic) {
// AND mode: single select
onChange(category);
inputValue = '';
dropdownOpen = false;
} else {
// OR mode: multi-select toggle
const currentSelected = Array.isArray(selected) ? selected : (selected ? [selected] : []);
if (currentSelected.includes(category)) {
const newSelected = currentSelected.filter(c => c !== category);
onChange(newSelected.length > 0 ? newSelected : null);
} else {
onChange([...currentSelected, category]);
}
inputValue = '';
}
}
function handleKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault();
const value = inputValue.trim();
const matchedCat = categories.find(c => c.toLowerCase() === value.toLowerCase())
|| filteredCategories[0];
if (matchedCat) {
handleCategorySelect(matchedCat);
}
} else if (event.key === 'Escape') {
dropdownOpen = false;
inputValue = '';
}
}
function handleRemove(category) {
if (useAndLogic) {
onChange(null);
} else {
const currentSelected = Array.isArray(selected) ? selected : (selected ? [selected] : []);
const newSelected = currentSelected.filter(c => c !== category);
onChange(newSelected.length > 0 ? newSelected : null);
}
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
max-width: 100%;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord2);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.filter-label {
color: var(--nord6);
}
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.input-wrapper {
position: relative;
}
input {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
border-radius: 6px;
width: 100%;
transition: all 100ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.9rem;
}
@media (max-width: 968px) {
input {
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
}
input::placeholder {
color: var(--nord4);
}
input:hover {
background: var(--nord2);
}
input:focus-visible {
outline: 2px solid var(--nord10);
outline-offset: 2px;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.3rem;
background: var(--nord0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.3));
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.selected-category {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<!-- Input with custom dropdown -->
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeyDown}
placeholder={selectLabel}
autocomplete="off"
/>
<!-- Custom dropdown with category chips -->
{#if dropdownOpen && filteredCategories.length > 0}
<div class="dropdown">
{#each filteredCategories as category}
<TagChip
tag={category}
selected={selectedArray.includes(category)}
removable={false}
onToggle={() => handleCategorySelect(category)}
/>
{/each}
</div>
{/if}
</div>
<!-- Selected categories display below -->
{#if selectedArray.length > 0}
<div class="selected-category">
{#each selectedArray as category}
<TagChip
tag={category}
selected={true}
removable={true}
onToggle={() => handleRemove(category)}
/>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,931 @@
<script lang='ts'>
import {flip} from "svelte/animate"
import Pen from '$lib/assets/icons/Pen.svelte'
import Cross from '$lib/assets/icons/Cross.svelte'
import Plus from '$lib/assets/icons/Plus.svelte'
import Check from '$lib/assets/icons/Check.svelte'
import "$lib/css/action_button.css"
import { do_on_key } from '$lib/components/recipes/do_on_key.js'
import { portions } from '$lib/js/portions_store.js'
import BaseRecipeSelector from '$lib/components/recipes/BaseRecipeSelector.svelte'
let portions_local = $state()
portions.subscribe((p) => {
portions_local = p
});
export function set_portions(){
portions.update((p) => portions_local)
}
let { lang = 'de' as 'de' | 'en', ingredients = $bindable() } = $props<{ lang?: 'de' | 'en', ingredients: any }>();
// Translation strings
const t = {
de: {
portions: 'Portionen:',
ingredients: 'Zutaten',
baseRecipe: 'Basisrezept',
unnamed: 'Unbenannt',
additionalIngredientsBefore: 'Zusätzliche Zutaten davor:',
additionalIngredientsAfter: 'Zusätzliche Zutaten danach:',
addIngredientBefore: 'Zutat davor hinzufügen',
addIngredientAfter: 'Zutat danach hinzufügen',
baseRecipeContent: '→ Inhalt vom Basisrezept wird hier eingefügt ←',
insertBaseRecipe: 'Basisrezept einfügen',
categoryOptional: 'Kategorie (optional)',
editIngredient: 'Zutat verändern',
renameCategory: 'Kategorie umbenennen',
confirmDeleteReference: 'Bist du dir sicher, dass du diese Referenz löschen möchtest?',
confirmDeleteList: 'Bist du dir sicher, dass du diese Liste löschen möchtest? Alle Zutaten der Liste werden hiermit auch gelöscht.',
empty: 'Leer',
editHeading: 'Überschrift bearbeiten',
removeList: 'Liste entfernen',
editIngredientAria: 'Zutat bearbeiten',
removeIngredientAria: 'Zutat entfernen',
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
moveReferenceUpAria: 'Referenz nach oben verschieben',
moveReferenceDownAria: 'Referenz nach unten verschieben',
removeReferenceAria: 'Referenz entfernen'
},
en: {
portions: 'Portions:',
ingredients: 'Ingredients',
baseRecipe: 'Base Recipe',
unnamed: 'Unnamed',
additionalIngredientsBefore: 'Additional ingredients before:',
additionalIngredientsAfter: 'Additional ingredients after:',
addIngredientBefore: 'Add ingredient before',
addIngredientAfter: 'Add ingredient after',
baseRecipeContent: '→ Base recipe content will be inserted here ←',
insertBaseRecipe: 'Insert Base Recipe',
categoryOptional: 'Category (optional)',
editIngredient: 'Edit Ingredient',
renameCategory: 'Rename Category',
confirmDeleteReference: 'Are you sure you want to delete this reference?',
confirmDeleteList: 'Are you sure you want to delete this list? All ingredients in the list will also be deleted.',
empty: 'Empty',
editHeading: 'Edit heading',
removeList: 'Remove list',
editIngredientAria: 'Edit ingredient',
removeIngredientAria: 'Remove ingredient',
moveUpAria: 'Move up',
moveDownAria: 'Move down',
moveReferenceUpAria: 'Move reference up',
moveReferenceDownAria: 'Move reference down',
removeReferenceAria: 'Remove reference'
}
};
let new_ingredient = $state({
amount: "",
unit: "",
name: "",
sublist: "",
});
let edit_ingredient = $state({
amount: "",
unit: "",
name: "",
sublist: "",
list_index: "",
ingredient_index: "",
});
let edit_heading = $state({
name:"",
list_index: "",
});
// Base recipe selector state
let showSelector = $state(false);
let insertPosition = $state(0);
// State for adding items to references
let addingToReference = $state({
active: false,
list_index: -1,
position: 'before' as 'before' | 'after',
editing: false,
item_index: -1
});
function openSelector(position: number) {
insertPosition = position;
showSelector = true;
}
function handleSelect(recipe: any, options: any) {
const reference = {
type: 'reference',
name: options.labelOverride || (options.showLabel ? recipe.name : ''),
baseRecipeRef: recipe._id,
includeIngredients: options.includeIngredients,
showLabel: options.showLabel,
labelOverride: options.labelOverride || '',
baseMultiplier: options.baseMultiplier || 1,
itemsBefore: [],
itemsAfter: []
};
ingredients.splice(insertPosition, 0, reference);
ingredients = ingredients;
showSelector = false;
}
export function removeReference(list_index: number) {
const confirmed = confirm(t[lang].confirmDeleteReference);
if (confirmed) {
ingredients.splice(list_index, 1);
ingredients = ingredients;
}
}
// Functions to manage items before/after base recipe in references
function addItemToReference(list_index: number, position: 'before' | 'after', item: any) {
if (!ingredients[list_index].itemsBefore) ingredients[list_index].itemsBefore = [];
if (!ingredients[list_index].itemsAfter) ingredients[list_index].itemsAfter = [];
if (position === 'before') {
ingredients[list_index].itemsBefore.push(item);
} else {
ingredients[list_index].itemsAfter.push(item);
}
ingredients = ingredients;
}
function removeItemFromReference(list_index: number, position: 'before' | 'after', item_index: number) {
if (position === 'before') {
ingredients[list_index].itemsBefore.splice(item_index, 1);
} else {
ingredients[list_index].itemsAfter.splice(item_index, 1);
}
ingredients = ingredients;
}
function editItemFromReference(list_index: number, position: 'before' | 'after', item_index: number) {
const items = position === 'before' ? ingredients[list_index].itemsBefore : ingredients[list_index].itemsAfter;
const item = items[item_index];
// Set up edit state
addingToReference = {
active: true,
list_index,
position,
editing: true,
item_index
};
edit_ingredient = {
amount: item.amount || "",
unit: item.unit || "",
name: item.name || "",
sublist: "",
list_index: "",
ingredient_index: "",
};
const modal_el = document.querySelector(`#edit_ingredient_modal-${lang}`) as HTMLDialogElement;
if (modal_el) {
modal_el.showModal();
}
}
function openAddToReferenceModal(list_index: number, position: 'before' | 'after') {
addingToReference = {
active: true,
list_index,
position,
editing: false,
item_index: -1
};
// Clear and open the edit ingredient modal for adding
edit_ingredient = {
amount: "",
unit: "",
name: "",
sublist: "",
list_index: "",
ingredient_index: "",
};
const modal_el = document.querySelector(`#edit_ingredient_modal-${lang}`) as HTMLDialogElement;
if (modal_el) {
modal_el.showModal();
}
}
function get_sublist_index(sublist_name, list){
for(var i =0; i < list.length; i++){
if(list[i].name == sublist_name){
return i
}
}
return -1
}
export function show_modal_edit_subheading_ingredient(list_index){
edit_heading.name = ingredients[list_index].name
edit_heading.list_index = list_index
const el = document.querySelector(`#edit_subheading_ingredient_modal-${lang}`)
el.showModal()
}
export function edit_subheading_and_close_modal(){
ingredients[edit_heading.list_index].name = edit_heading.name
const el = document.querySelector(`#edit_subheading_ingredient_modal-${lang}`)
el.close()
}
function handleIngredientModalCancel() {
// Reset reference adding state when modal is cancelled (Escape key)
addingToReference = {
active: false,
list_index: -1,
position: 'before',
editing: false,
item_index: -1
};
}
export function add_new_ingredient(){
if(!new_ingredient.name){
return
}
let list_index = get_sublist_index(new_ingredient.sublist, ingredients)
if(list_index == -1){
ingredients.push({
name: new_ingredient.sublist,
list: [],
})
list_index = ingredients.length - 1
}
ingredients[list_index].list.push({ ...new_ingredient})
ingredients = ingredients //tells svelte to update dom
}
export function remove_list(list_index){
if(ingredients[list_index].list.length > 1){
const response = confirm(t[lang].confirmDeleteList);
if(!response){
return
}
}
ingredients.splice(list_index, 1);
ingredients = ingredients //tells svelte to update dom
}
export function remove_ingredient(list_index, ingredient_index){
ingredients[list_index].list.splice(ingredient_index, 1)
ingredients = ingredients //tells svelte to update dom
}
export function show_modal_edit_ingredient(list_index, ingredient_index){
edit_ingredient = {...ingredients[list_index].list[ingredient_index]}
edit_ingredient.list_index = list_index
edit_ingredient.ingredient_index = ingredient_index
edit_ingredient.sublist = ingredients[list_index].name
const modal_el = document.querySelector(`#edit_ingredient_modal-${lang}`);
modal_el.showModal();
}
export function edit_ingredient_and_close_modal(){
// Check if we're adding to or editing a reference
if (addingToReference.active) {
// Don't add empty ingredients
if (!edit_ingredient.name) {
addingToReference = {
active: false,
list_index: -1,
position: 'before',
editing: false,
item_index: -1
};
const modal_el = document.querySelector(`#edit_ingredient_modal-${lang}`) as HTMLDialogElement;
if (modal_el) {
setTimeout(() => modal_el.close(), 0);
}
return;
}
const item = {
amount: edit_ingredient.amount,
unit: edit_ingredient.unit,
name: edit_ingredient.name
};
if (addingToReference.editing) {
// Edit existing item in reference
const items = addingToReference.position === 'before'
? ingredients[addingToReference.list_index].itemsBefore
: ingredients[addingToReference.list_index].itemsAfter;
items[addingToReference.item_index] = item;
ingredients = ingredients;
} else {
// Add new item to reference
addItemToReference(addingToReference.list_index, addingToReference.position, item);
}
addingToReference = {
active: false,
list_index: -1,
position: 'before',
editing: false,
item_index: -1
};
} else {
// Normal edit behavior
ingredients[edit_ingredient.list_index].list[edit_ingredient.ingredient_index] = {
amount: edit_ingredient.amount,
unit: edit_ingredient.unit,
name: edit_ingredient.name,
}
ingredients[edit_ingredient.list_index].name = edit_ingredient.sublist
}
const modal_el = document.querySelector(`#edit_ingredient_modal-${lang}`) as HTMLDialogElement;
if (modal_el) {
// Defer closing to next tick to ensure all bindings are updated
setTimeout(() => modal_el.close(), 0);
}
}
export function update_list_position(list_index, direction){
if(direction == 1){
if(list_index == 0){
return
}
ingredients.splice(list_index - 1, 0, ingredients.splice(list_index, 1)[0])
}
else if(direction == -1){
if(list_index == ingredients.length - 1){
return
}
ingredients.splice(list_index + 1, 0, ingredients.splice(list_index, 1)[0])
}
ingredients = ingredients //tells svelte to update dom
}
export function update_ingredient_position(list_index, ingredient_index, direction){
if(direction == 1){
if(ingredient_index == 0){
return
}
ingredients[list_index].list.splice(ingredient_index - 1, 0, ingredients[list_index].list.splice(ingredient_index, 1)[0])
}
else if(direction == -1){
if(ingredient_index == ingredients[list_index].list.length - 1){
return
}
ingredients[list_index].list.splice(ingredient_index + 1, 0, ingredients[list_index].list.splice(ingredient_index, 1)[0])
}
ingredients = ingredients //tells svelte to update dom
}
</script>
<style>
input::placeholder{
color: inherit;
}
input{
color: unset;
font-size: unset;
padding: unset;
background-color: unset;
}
input.heading{
all: unset;
box-sizing: border-box;
background-color: var(--nord0);
padding: 1rem;
padding-inline: 2rem;
font-size: 1.5rem;
width: 100%;
border-radius: 1000px;
color: white;
justify-content: center;
align-items: center;
transition: 200ms;
}
input.heading:hover{
background-color: var(--nord1);
}
.heading_wrapper{
position: relative;
width: 300px;
margin-inline: auto;
transition: 200ms;
}
.heading_wrapper:hover
{
transform:scale(1.1,1.1);
}
.heading_wrapper button{
position: absolute;
bottom: -1.5rem;
right: -2rem;
}
.adder{
box-sizing: border-box;
margin-inline: auto;
position: relative;
margin-block: 3rem;
width: 90%;
border-radius: 20px;
transition: 200ms;
}
.shadow{
box-shadow: 0 0 1em 0.2em rgba(0,0,0,0.3);
}
.shadow:hover{
box-shadow: 0 0 1em 0.4em rgba(0,0,0,0.3);
}
.adder button{
position: absolute;
right: -1.5rem;
bottom: -1.5rem;
}
.category{
border: none;
position: absolute;
--font_size: 1.5rem;
top: -1em;
left: -1em;
font-family: sans-serif;
font-size: 1.5rem;
background-color: var(--nord0);
color: var(--nord4);
border-radius: 1000000px;
width: 23ch;
padding: 0.5em 1em;
transition: 100ms;
box-shadow: 0.5em 0.5em 1em 0.4em rgba(0,0,0,0.3);
}
.category:hover{
background-color: var(--nord1);
transform: scale(1.05,1.05);
}
.adder:hover,
.adder:focus-within
{
transform: scale(1.05, 1.05);
}
.add_ingredient{
font-family: sans-serif;
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
font-size: 1.2rem;
padding: 2rem;
padding-top: 2.5rem;
border-radius: 20px;
background-color: var(--blue);
color: #bbb;
transition: 200ms;
gap: 0.5rem;
}
.add_ingredient input{
border: 2px solid var(--nord4);
color: var(--nord4);
border-radius: 1000px;
padding: 0.5em 1em;
transition: 100ms;
}
.add_ingredient input:hover,
.add_ingredient input:focus-visible
{
border-color: white;
color: white;
transform: scale(1.02, 1.02);
}
.add_ingredient input:nth-of-type(1){
max-width: 8ch;
}
.add_ingredient input:nth-of-type(2){
max-width: 8ch;
}
.add_ingredient input:nth-of-type(3){
max-width: 30ch;
}
dialog{
box-sizing: content-box;
width: 100%;
height: 100%;
background-color: transparent;
border: unset;
margin: 0;
transition: 500ms;
}
dialog[open]::backdrop{
animation: show 200ms ease forwards;
}
@keyframes show{
from {
backdrop-filter: blur(0px);
}
to {
backdrop-filter: blur(10px);
}
}
dialog .adder{
margin-top: 5rem;
}
dialog h2{
font-size: 3rem;
font-family: sans-serif;
color: white;
text-align: center;
margin-top: 30vh;
margin-top: 30dvh;
filter: drop-shadow(0 0 0.4em black)
drop-shadow(0 0 1em black)
;
}
.mod_icons{
display: flex;
flex-direction: row;
}
.button_subtle{
padding: 0em;
animation: unset;
margin: 0.2em 0.1em;
background-color: transparent;
box-shadow: unset;
}
.button_subtle:hover{
scale: 1.2 1.2;
}
.move_buttons_container{
display: flex;
flex-direction: column;
}
.move_buttons_container button{
background-color: transparent;
border: none;
padding: 0;
margin: 0;
transition: 200ms;
}
.move_buttons_container button:hover{
scale: 1.4;
}
h3{
width: fit-content;
display: flex;
flex-direction: row;
align-items: center;
max-width: 1000px;
justify-content: space-between;
user-select: none;
cursor: pointer;
gap: 1em;
}
.ingredients_grid{
box-sizing: border-box;
display: grid;
font-size: 1.1em;
grid-template-columns: 0.5fr 2fr 3fr 1fr;
grid-template-rows: auto;
grid-auto-flow: row;
align-items: center;
row-gap: 0.5em;
column-gap: 0.5em;
}
.ingredients_grid > *{
cursor: pointer;
user-select: none;
}
.ingredients_grid>*:nth-child(3n+1){
min-width: 5ch;
}
.list_wrapper{
padding-inline: 2em;
padding-block: 1em;
}
.list_wrapper p[contenteditable]{
border: 2px solid grey;
border-radius: 1000px;
padding: 0.25em 1em;
background-color: white;
transition: 200ms;
}
.list_wrapper p[contenteditable]:hover,
.list_wrapper p[contenteditable]:focus-within{
scale: 1.05 1.05;
}
@media screen and (max-width: 500px){
dialog h2{
margin-top: 2rem;
}
dialog .heading_wrapper{
width: 80%;
}
.ingredients_grid .mod_icons{
margin-left: 0;
}
}
.force_wrap{
overflow-wrap: break-word;
}
.button_arrow{
fill: var(--nord1);
}
@media (prefers-color-scheme: dark){
.button_arrow{
fill: var(--nord4);
}
.list_wrapper p[contenteditable]{
background-color: var(--accent-dark);
}
}
/* Styling for converted div-to-button elements */
.subheading-button {
all: unset;
cursor: pointer;
user-select: none;
display: block;
width: 100%;
text-align: left;
}
.ingredient-amount-button, .ingredient-name-button {
all: unset;
cursor: pointer;
user-select: none;
display: block;
width: 100%;
text-align: left;
}
/* Base recipe reference styles */
.reference-container {
margin-block: 1em;
padding: 1em;
background-color: var(--nord14);
border-radius: 10px;
border: 2px solid var(--nord9);
box-shadow: 0 0 0.5em 0.1em rgba(0,0,0,0.2);
}
.reference-header {
display: flex;
align-items: center;
gap: 1em;
margin-bottom: 0.5em;
}
.reference-badge {
flex-grow: 1;
font-weight: bold;
color: var(--nord0);
font-size: 1.1rem;
}
@media (prefers-color-scheme: dark) {
.reference-container {
background-color: var(--nord1);
}
.reference-badge {
color: var(--nord6);
}
}
.insert-base-recipe-button {
margin-block: 1rem;
padding: 1em 2em;
font-size: 1.1rem;
border-radius: 1000px;
background-color: var(--nord9);
color: white;
border: none;
cursor: pointer;
transition: 200ms;
box-shadow: 0 0 0.5em 0.1em rgba(0,0,0,0.2);
}
.insert-base-recipe-button:hover {
transform: scale(1.05, 1.05);
box-shadow: 0 0 1em 0.2em rgba(0,0,0,0.3);
}
.add-to-reference-button {
color: white;
}
.add-to-reference-button:hover {
scale: 1.02 1.02 !important;
transform: scale(1.02) !important;
}
</style>
<div class=list_wrapper >
<h4>{t[lang].portions}</h4>
<p contenteditable type="text" bind:innerText={portions_local} onblur={set_portions}></p>
<h2>{t[lang].ingredients}</h2>
{#each ingredients as list, list_index}
{#if list.type === 'reference'}
<!-- Reference item display -->
<div class="reference-container">
<div class="reference-header">
<div class="move_buttons_container">
<button type="button" onclick={() => update_list_position(list_index, 1)} aria-label={t[lang].moveReferenceUpAria}>
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button type="button" onclick={() => update_list_position(list_index, -1)} aria-label={t[lang].moveReferenceDownAria}>
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
<div class="reference-badge">
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
<div style="margin-top: 0.5em;">
<label style="font-size: 0.9em; display: flex; align-items: center; gap: 0.5em;">
{t[lang].baseMultiplier || 'Mengenfaktor'}:
<input
type="number"
bind:value={list.baseMultiplier}
min="0.1"
step="0.1"
style="width: 5em; padding: 0.25em 0.5em; border-radius: 5px; border: 1px solid var(--nord4);"
/>
</label>
</div>
</div>
<div class="mod_icons">
<button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
<Cross fill="var(--nord11)"></Cross>
</button>
</div>
</div>
<!-- Items before base recipe -->
{#if list.itemsBefore && list.itemsBefore.length > 0}
<h4 style="margin-block: 0.5em; color: var(--nord9);">{t[lang].additionalIngredientsBefore}</h4>
<div class="ingredients_grid">
{#each list.itemsBefore as item, item_index}
<div class=move_buttons_container>
<!-- Empty for consistency -->
</div>
<button type="button" onclick={() => editItemFromReference(list_index, 'before', item_index)} class="ingredient-amount-button">
{item.amount} {item.unit}
</button>
<button type="button" class="force_wrap ingredient-name-button" onclick={() => editItemFromReference(list_index, 'before', item_index)}>
{@html item.name}
</button>
<div class="mod_icons">
<button type="button" class="action_button button_subtle" onclick={() => editItemFromReference(list_index, 'before', item_index)} aria-label={t[lang].editIngredientAria}>
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
</button>
<button type="button" class="action_button button_subtle" onclick={() => removeItemFromReference(list_index, 'before', item_index)} aria-label={t[lang].removeIngredientAria}>
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
</button>
</div>
{/each}
</div>
{/if}
<button type="button" class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'before')}>
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addIngredientBefore}
</button>
<!-- Base recipe content indicator -->
<div style="text-align: center; padding: 0.5em; margin: 0.5em 0; font-style: italic; color: var(--nord10); background-color: rgba(143, 188, 187, 0.4); border-radius: 5px;">
{t[lang].baseRecipeContent}
</div>
<!-- Items after base recipe -->
<button type="button" class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'after')}>
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addIngredientAfter}
</button>
{#if list.itemsAfter && list.itemsAfter.length > 0}
<h4 style="margin-block: 0.5em; color: var(--nord9);">{t[lang].additionalIngredientsAfter}</h4>
<div class="ingredients_grid">
{#each list.itemsAfter as item, item_index}
<div class=move_buttons_container>
<!-- Empty for consistency -->
</div>
<button type="button" onclick={() => editItemFromReference(list_index, 'after', item_index)} class="ingredient-amount-button">
{item.amount} {item.unit}
</button>
<button type="button" class="force_wrap ingredient-name-button" onclick={() => editItemFromReference(list_index, 'after', item_index)}>
{@html item.name}
</button>
<div class="mod_icons">
<button type="button" class="action_button button_subtle" onclick={() => editItemFromReference(list_index, 'after', item_index)} aria-label={t[lang].editIngredientAria}>
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
</button>
<button type="button" class="action_button button_subtle" onclick={() => removeItemFromReference(list_index, 'after', item_index)} aria-label={t[lang].removeIngredientAria}>
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
</button>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<h3>
<div class=move_buttons_container>
<button type="button" onclick="{() => update_list_position(list_index, 1)}" aria-label="Liste nach oben verschieben">
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button type="button" onclick="{() => update_list_position(list_index, -1)}" aria-label="Liste nach unten verschieben">
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
<button type="button" onclick="{() => show_modal_edit_subheading_ingredient(list_index)}" class="subheading-button">
{#if list.name }
{list.name}
{:else}
{t[lang].empty}
{/if}
</button>
<div class=mod_icons>
<button type="button" class="action_button button_subtle" onclick="{() => show_modal_edit_subheading_ingredient(list_index)}" aria-label={t[lang].editHeading}>
<Pen fill=var(--nord1)></Pen> </button>
<button type="button" class="action_button button_subtle" onclick="{() => remove_list(list_index)}" aria-label={t[lang].removeList}>
<Cross fill=var(--nord1)></Cross></button>
</div>
</h3>
<div class=ingredients_grid>
{#each list.list as ingredient, ingredient_index (ingredient_index)}
<div class=move_buttons_container>
<button type="button" onclick="{() => update_ingredient_position(list_index, ingredient_index, 1)}" aria-label={t[lang].moveUpAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button type="button" onclick="{() => update_ingredient_position(list_index, ingredient_index, -1)}" aria-label={t[lang].moveDownAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
<button type="button" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)} class="ingredient-amount-button">
{ingredient.amount} {ingredient.unit}
</button>
<button type="button" class="force_wrap ingredient-name-button" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)}>
{@html ingredient.name}
</button>
<div class=mod_icons><button type="button" class="action_button button_subtle" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)} aria-label={t[lang].editIngredientAria}>
<Pen fill=var(--nord1) height=1em width=1em></Pen></button>
<button type="button" class="action_button button_subtle" onclick="{() => remove_ingredient(list_index, ingredient_index)}" aria-label={t[lang].removeIngredientAria}><Cross fill=var(--nord1) height=1em width=1em></Cross></button></div>
{/each}
</div>
{/if}
{/each}
<!-- Button to insert base recipe -->
<button type="button" class="insert-base-recipe-button" onclick={() => openSelector(ingredients.length)}>
<Plus fill="white" style="display: inline; width: 1.5em; height: 1.5em; vertical-align: middle;"></Plus>
{t[lang].insertBaseRecipe}
</button>
</div>
<div class="adder shadow">
<input class=category type="text" bind:value={new_ingredient.sublist} placeholder={t[lang].categoryOptional} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
<div class=add_ingredient>
<input type="text" placeholder="250..." bind:value={new_ingredient.amount} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
<input type="text" placeholder="mL..." bind:value={new_ingredient.unit} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
<input type="text" placeholder="Milch..." bind:value={new_ingredient.name} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
<button type="button" onclick={() => add_new_ingredient()} class=action_button>
<Plus fill=white style="width: 2rem; height: 2rem;"></Plus>
</button>
</div>
</div>
<dialog id="edit_ingredient_modal-{lang}" oncancel={handleIngredientModalCancel}>
<h2>{t[lang].editIngredient}</h2>
<div class=adder>
<input class=category type="text" bind:value={edit_ingredient.sublist} placeholder={t[lang].categoryOptional}>
<div class=add_ingredient role="group">
<input type="text" placeholder="250..." bind:value={edit_ingredient.amount} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<input type="text" placeholder="mL..." bind:value={edit_ingredient.unit} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<input type="text" placeholder="Milch..." bind:value={edit_ingredient.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<button type="button" class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)} onclick={edit_ingredient_and_close_modal}>
<Check fill=white style="width: 2rem; height: 2rem;"></Check>
</button>
</div>
</div>
</dialog>
<dialog id="edit_subheading_ingredient_modal-{lang}">
<h2>{t[lang].renameCategory}</h2>
<div class=heading_wrapper>
<input class=heading type="text" bind:value={edit_heading.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} >
<button type="button" class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} onclick={edit_subheading_and_close_modal}>
<Check fill=white style="width:2rem; height:2rem;"></Check>
</button>
</div>
</dialog>
<!-- Base recipe selector -->
<BaseRecipeSelector
type="ingredients"
onSelect={handleSelect}
bind:open={showSelector}
/>
@@ -0,0 +1,985 @@
<script lang='ts'>
import Pen from '$lib/assets/icons/Pen.svelte'
import Cross from '$lib/assets/icons/Cross.svelte'
import Plus from '$lib/assets/icons/Plus.svelte'
import Check from '$lib/assets/icons/Check.svelte'
import '$lib/css/nordtheme.css'
import "$lib/css/action_button.css"
import { do_on_key } from '$lib/components/recipes/do_on_key.js'
import BaseRecipeSelector from '$lib/components/recipes/BaseRecipeSelector.svelte'
let { lang = 'de' as 'de' | 'en', instructions = $bindable(), add_info = $bindable() } = $props<{ lang?: 'de' | 'en', instructions: any, add_info: any }>();
// Translation strings
const t = {
de: {
preparation: 'Vorbereitung:',
bulkFermentation: 'Stockgare:',
finalFermentation: 'Stückgare:',
baking: 'Backen:',
cooking: 'Kochen:',
totalTime: 'Auf dem Teller:',
instructions: 'Zubereitung',
baseRecipe: 'Basisrezept',
unnamed: 'Unbenannt',
additionalStepsBefore: 'Zusätzliche Schritte davor:',
additionalStepsAfter: 'Zusätzliche Schritte danach:',
addStepBefore: 'Schritt davor hinzufügen',
addStepAfter: 'Schritt danach hinzufügen',
baseRecipeContent: '→ Inhalt vom Basisrezept wird hier eingefügt ←',
insertBaseRecipe: 'Basisrezept einfügen',
categoryOptional: 'Kategorie (optional)',
subcategoryOptional: 'Unterkategorie (optional)',
editStep: 'Schritt verändern',
renameCategory: 'Kategorie umbenennen',
confirmDeleteReference: 'Bist du dir sicher, dass du diese Referenz löschen möchtest?',
confirmDeleteList: 'Bist du dir sicher, dass du diese Liste löschen möchtest? Alle Zubereitungsschritte der Liste werden hiermit auch gelöscht.',
empty: 'Leer',
editHeading: 'Überschrift bearbeiten',
removeList: 'Liste entfernen',
editStepAria: 'Schritt bearbeiten',
removeStepAria: 'Schritt entfernen',
moveUpAria: 'Nach oben verschieben',
moveDownAria: 'Nach unten verschieben',
moveReferenceUpAria: 'Referenz nach oben verschieben',
moveReferenceDownAria: 'Referenz nach unten verschieben',
removeReferenceAria: 'Referenz entfernen',
moveListUpAria: 'Liste nach oben verschieben',
moveListDownAria: 'Liste nach unten verschieben'
},
en: {
preparation: 'Preparation:',
bulkFermentation: 'Bulk Fermentation:',
finalFermentation: 'Final Fermentation:',
baking: 'Baking:',
cooking: 'Cooking:',
totalTime: 'Total Time:',
instructions: 'Instructions',
baseRecipe: 'Base Recipe',
unnamed: 'Unnamed',
additionalStepsBefore: 'Additional steps before:',
additionalStepsAfter: 'Additional steps after:',
addStepBefore: 'Add step before',
addStepAfter: 'Add step after',
baseRecipeContent: '→ Base recipe content will be inserted here ←',
insertBaseRecipe: 'Insert Base Recipe',
categoryOptional: 'Category (optional)',
subcategoryOptional: 'Subcategory (optional)',
editStep: 'Edit Step',
renameCategory: 'Rename Category',
confirmDeleteReference: 'Are you sure you want to delete this reference?',
confirmDeleteList: 'Are you sure you want to delete this list? All preparation steps in the list will also be deleted.',
empty: 'Empty',
editHeading: 'Edit heading',
removeList: 'Remove list',
editStepAria: 'Edit step',
removeStepAria: 'Remove step',
moveUpAria: 'Move up',
moveDownAria: 'Move down',
moveReferenceUpAria: 'Move reference up',
moveReferenceDownAria: 'Move reference down',
removeReferenceAria: 'Remove reference',
moveListUpAria: 'Move list up',
moveListDownAria: 'Move list down'
}
};
const step_placeholder = "Kartoffeln schälen..."
let new_step = $state({
name: "",
step: step_placeholder
});
let edit_heading = $state({
name:"",
list_index: "",
});
// Base recipe selector state
let showSelector = $state(false);
let insertPosition = $state(0);
// State for adding steps to references
let addingToReference = $state({
active: false,
list_index: -1,
position: 'before' as 'before' | 'after',
editing: false,
step_index: -1
});
function openSelector(position: number) {
insertPosition = position;
showSelector = true;
}
function handleSelect(recipe: any, options: any) {
const reference = {
type: 'reference',
name: options.labelOverride || (options.showLabel ? recipe.name : ''),
baseRecipeRef: recipe._id,
includeInstructions: options.includeInstructions,
showLabel: options.showLabel,
labelOverride: options.labelOverride || '',
baseMultiplier: options.baseMultiplier || 1,
stepsBefore: [],
stepsAfter: []
};
instructions.splice(insertPosition, 0, reference);
instructions = instructions;
showSelector = false;
}
export function removeReference(list_index: number) {
const confirmed = confirm(t[lang].confirmDeleteReference);
if (confirmed) {
instructions.splice(list_index, 1);
instructions = instructions;
}
}
// Functions to manage steps before/after base recipe in references
function addStepToReference(list_index: number, position: 'before' | 'after', step: string) {
if (!instructions[list_index].stepsBefore) instructions[list_index].stepsBefore = [];
if (!instructions[list_index].stepsAfter) instructions[list_index].stepsAfter = [];
if (position === 'before') {
instructions[list_index].stepsBefore.push(step);
} else {
instructions[list_index].stepsAfter.push(step);
}
instructions = instructions;
}
function removeStepFromReference(list_index: number, position: 'before' | 'after', step_index: number) {
if (position === 'before') {
instructions[list_index].stepsBefore.splice(step_index, 1);
} else {
instructions[list_index].stepsAfter.splice(step_index, 1);
}
instructions = instructions;
}
function editStepFromReference(list_index: number, position: 'before' | 'after', step_index: number) {
const steps = position === 'before' ? instructions[list_index].stepsBefore : instructions[list_index].stepsAfter;
const step = steps[step_index];
// Set up edit state
addingToReference = {
active: true,
list_index,
position,
editing: true,
step_index
};
edit_step = {
step: step || "",
name: "",
list_index: 0,
step_index: 0,
};
const modal_el = document.querySelector(`#edit_step_modal-${lang}`) as HTMLDialogElement;
if (modal_el) {
modal_el.showModal();
}
}
function openAddToReferenceModal(list_index: number, position: 'before' | 'after') {
addingToReference = {
active: true,
list_index,
position,
editing: false,
step_index: -1
};
// Clear and open the edit step modal for adding
edit_step = {
step: "",
name: "",
list_index: 0,
step_index: 0,
};
const modal_el = document.querySelector(`#edit_step_modal-${lang}`) as HTMLDialogElement;
if (modal_el) {
modal_el.showModal();
}
}
function get_sublist_index(sublist_name, list){
for(var i =0; i < list.length; i++){
if(list[i].name == sublist_name){
return i
}
}
return -1
}
export function remove_list(list_index){
if(instructions[list_index].steps.length > 1){
const response = confirm(t[lang].confirmDeleteList);
if(!response){
return
}
}
instructions.splice(list_index, 1);
instructions = instructions //tells svelte to update dom
}
export function add_new_step(){
if(new_step.step == step_placeholder){
return
}
let list_index = get_sublist_index(new_step.name, instructions)
if(list_index == -1){
instructions.push({
name: new_step.name,
steps: [ new_step.step ],
})
list_index = instructions.length - 1
}
else{
instructions[list_index].steps.push(new_step.step)
}
const el = document.querySelector("#step")
el.innerHTML = ""
new_step.step = ""
instructions = instructions //tells svelte to update dom
}
export function remove_step(list_index, step_index){
instructions[list_index].steps.splice(step_index, 1)
instructions = instructions //tells svelte to update dom
}
let edit_step = $state({
name: "",
step: "",
list_index: 0,
step_index: 0,
});
export function show_modal_edit_step(list_index, step_index){
edit_step = {
step: instructions[list_index].steps[step_index],
name: instructions[list_index].name,
}
edit_step.list_index = list_index
edit_step.step_index = step_index
const modal_el = document.querySelector(`#edit_step_modal-${lang}`);
modal_el.showModal();
}
export function edit_step_and_close_modal(){
// Check if we're adding to or editing a reference
if (addingToReference.active) {
// Don't add empty steps
if (!edit_step.step || edit_step.step.trim() === '') {
addingToReference = {
active: false,
list_index: -1,
position: 'before',
editing: false,
step_index: -1
};
const modal_el = document.querySelector(`#edit_step_modal-${lang}`) as HTMLDialogElement;
if (modal_el) {
setTimeout(() => modal_el.close(), 0);
}
return;
}
if (addingToReference.editing) {
// Edit existing step in reference
const steps = addingToReference.position === 'before'
? instructions[addingToReference.list_index].stepsBefore
: instructions[addingToReference.list_index].stepsAfter;
steps[addingToReference.step_index] = edit_step.step;
instructions = instructions;
} else {
// Add new step to reference
addStepToReference(addingToReference.list_index, addingToReference.position, edit_step.step);
}
addingToReference = {
active: false,
list_index: -1,
position: 'before',
editing: false,
step_index: -1
};
} else {
// Normal edit behavior
instructions[edit_step.list_index].steps[edit_step.step_index] = edit_step.step
}
const modal_el = document.querySelector(`#edit_step_modal-${lang}`) as HTMLDialogElement;
if (modal_el) {
// Defer closing to next tick to ensure all bindings are updated
setTimeout(() => modal_el.close(), 0);
}
}
export function show_modal_edit_subheading_step(list_index){
edit_heading.name = instructions[list_index].name
edit_heading.list_index = list_index
const el = document.querySelector(`#edit_subheading_steps_modal-${lang}`)
el.showModal()
}
export function edit_subheading_steps_and_close_modal(){
instructions[edit_heading.list_index].name = edit_heading.name
const modal_el = document.querySelector("#edit_subheading_steps_modal");
modal_el.close();
}
function handleStepModalCancel() {
// Reset reference adding state when modal is cancelled (Escape key)
addingToReference = {
active: false,
list_index: -1,
position: 'before',
editing: false,
step_index: -1
};
}
export function clear_step(){
const el = document.querySelector("#step")
if(el.innerHTML == step_placeholder){
el.innerHTML = ""
}
}
export function add_placeholder(){
const el = document.querySelector("#step")
if(el.innerHTML == ""){
el.innerHTML = step_placeholder
}
}
export function update_list_position(list_index, direction){
if(direction == 1){
if(list_index == 0){
return
}
instructions.splice(list_index - 1, 0, instructions.splice(list_index, 1)[0])
}
else if(direction == -1){
if(list_index == instructions.length - 1){
return
}
instructions.splice(list_index + 1, 0, instructions.splice(list_index, 1)[0])
}
instructions = instructions //tells svelte to update dom
}
export function update_step_position(list_index, step_index, direction){
if(direction == 1){
if(step_index == 0){
return
}
instructions[list_index].steps.splice(step_index - 1, 0, instructions[list_index].steps.splice(step_index, 1)[0])
}
else if(direction == -1){
if(step_index == instructions[list_index].steps.length - 1){
return
}
instructions[list_index].steps.splice(step_index + 1, 0, instructions[list_index].steps.splice(step_index, 1)[0])
}
instructions = instructions //tells svelte to update dom
}
</script>
<style>
.move_buttons_container{
display: inline-flex;
flex-direction: column;
}
.move_buttons_container button{
background-color: transparent;
border: none;
padding: 0;
margin: 0;
transition: 200ms;
}
.move_buttons_container button:hover{
scale: 1.4;
}
.step_move_buttons{
position: absolute;
left: -2.5rem;
flex-direction: column;
}
input::placeholder{
all:unset;
}
li {
position: relative;
}
li > div{
display:flex;
flex-direction: row;
justify-items: space-between;
align-items:center;
user-select: none;
}
li > div > div:first-child{
flex-grow: 1;
cursor: pointer;
}
li > div > div:last-child{
display: flex;
flex-direction: row;
}
input.heading{
box-sizing: border-box;
background-color: var(--nord0);
padding: 1rem;
padding-inline: 2rem;
font-size: 1.5rem;
width: 100%;
border-radius: 1000px;
color: white;
justify-content: center;
align-items: center;
transition: 200ms;
}
input.heading:hover,
input.heading:focus-visible
{
background-color: var(--nord1);
}
.heading_wrapper{
position: relative;
width: min(300px, 95dvw);
margin-inline: auto;
transition: 200ms;
}
.heading_wrapper:hover,
.heading_wrapper:focus-visible
{
transform:scale(1.1,1.1);
}
.heading_wrapper button{
position: absolute;
bottom: -1.5rem;
right: -1.5rem;
}
.adder{
margin-inline: auto;
position: relative;
margin-block: 3rem;
width: 90%;
border-radius: 20px;
transition: 200ms;
background-color: var(--blue);
padding: 1.5rem 2rem;
}
dialog .adder{
width: 400px;
}
.shadow{
box-shadow: 0 0 1em 0.2em rgba(0,0,0,0.3);
}
.adder button{
position: absolute;
right: -1.5rem;
bottom: -1.5rem;
}
.category{
position: absolute;
border: none;
--font_size: 1.5rem;
top: -1em;
left: -1em;
font-family: sans-serif;
font-size: 1.5rem;
background-color: var(--nord0);
color: var(--nord4);
border-radius: 1000000px;
width: 23ch;
padding: 0.5em 1em;
transition: 100ms;
box-shadow: 0.5em 0.5em 1em 0.4em rgba(0,0,0,0.3);
}
.category:hover,
.category:focus-visible
{
background-color: var(--nord1);
transform: scale(1.05,1.05);
}
.adder:hover,
.adder:focus-within
{
transform: scale(1.1, 1.1);
}
.add_step p{
font-family: sans-serif;
width: 100%;
font-size: 1.2rem;
border-radius: 20px;
border: 2px solid var(--nord4);
border-radius: 30px;
padding: 0.5em 1em;
color: var(--nord4);
transition: 100ms;
}
.add_step p:hover,
.add_step p:focus-visible
{
color: white;
scale: 1.02 1.02;
}
dialog{
box-sizing: content-box;
width: 100%;
height: 100%;
background-color: rgba(255,255,255, 0.001);
border: unset;
margin: 0;
transition: 200ms;
}
dialog .adder{
margin-top: 5rem;
}
dialog h2{
font-size: 3rem;
font-family: sans-serif;
color: white;
text-align: center;
margin-top: 30vh;
margin-top: 30dvh;
filter: drop-shadow(0 0 0.4em black)
drop-shadow(0 0 1em black)
;
}
dialog .adder input::placeholder{
font-size: 1.2rem;
}
@media screen and (max-width: 500px){
dialog h2{
margin-top: 2rem;
}
dialog .adder{
width: 85%;
padding-inline: 0.5em;
}
dialog .adder .category{
width: 70%;
}
}
dialog[open]::backdrop{
animation: show 200ms ease forwards;
}
@keyframes show{
from {
backdrop-filter: blur(0px);
}
to {
backdrop-filter: blur(10px);
}
}
ol li::marker{
font-weight: bold;
color: var(--blue);
font-size: 1.2rem;
}
.instructions{
flex-basis: 0;
flex-grow: 2;
background-color: var(--nord5);
padding-block: 1rem;
padding-inline: 2rem;
}
.instructions ol{
padding-left: 1em;
}
.instructions li{
margin-block: 0.5em;
font-size: 1.1rem;
}
.additional_info{
display: flex;
flex-wrap: wrap;
gap: 1em;
}
.additional_info > *{
flex-grow: 0;
overflow: hidden;
padding: 1em;
background-color: #FAFAFE;
box-shadow: 0.3em 0.3em 1em 0.2em rgba(0,0,0,0.3);
/*max-width: 30%*/
}
.additional_info > div > *:not(h4){
line-height: 2em;
}
h4{
line-height: 1em;
margin-block: 0;
}
.button_subtle{
padding: 0em;
animation: unset;
margin: 0.2em 0.1em;
background-color: transparent;
box-shadow: unset;
display:inline;
}
.button_subtle:hover{
scale: 1.2 1.2;
}
h3{
display:flex;
gap: 1rem;
cursor: pointer;
user-select: none;
}
.additional_info p[contenteditable]{
display: inline;
padding: 0.25em 1em;
border: 2px solid grey;
border-radius: 1000px;
}
.additional_info div:has(p[contenteditable]){
transition: 200ms;
display: inline;
}
.additional_info div:has(p[contenteditable]):hover,
.additional_info div:has(p[contenteditable]):focus-within
{
transform: scale(1.1, 1.1);
}
@media screen and (max-width: 500px){
dialog h2{
margin-top: 2rem;
}
dialog .heading_wrapper{
width: 80%;
}
}
@media (prefers-color-scheme: dark){
.additional_info div{
background-color: var(--accent-dark);
}
.instructions{
background-color: var(--nord6-dark);
}
}
.button_arrow{
fill: var(--nord1);
}
@media (prefers-color-scheme: dark){
.button_arrow{
fill: var(--nord4);
}
}
/* Styling for converted div-to-button elements */
.subheading-button, .step-button {
all: unset;
cursor: pointer;
user-select: none;
display: block;
width: 100%;
text-align: left;
}
/* Base recipe reference styles */
.reference-container {
margin-block: 1em;
padding: 1em;
background-color: var(--nord14);
border-radius: 10px;
border: 2px solid var(--nord9);
box-shadow: 0 0 0.5em 0.1em rgba(0,0,0,0.2);
}
.reference-header {
display: flex;
align-items: center;
gap: 1em;
margin-bottom: 0.5em;
}
.reference-badge {
flex-grow: 1;
font-weight: bold;
color: var(--nord0);
font-size: 1.1rem;
}
@media (prefers-color-scheme: dark) {
.reference-container {
background-color: var(--nord1);
}
.reference-badge {
color: var(--nord6);
}
}
.insert-base-recipe-button {
margin-block: 1rem;
padding: 1em 2em;
font-size: 1.1rem;
border-radius: 1000px;
background-color: var(--nord9);
color: white;
border: none;
cursor: pointer;
transition: 200ms;
box-shadow: 0 0 0.5em 0.1em rgba(0,0,0,0.2);
}
.insert-base-recipe-button:hover {
transform: scale(1.05, 1.05);
box-shadow: 0 0 1em 0.2em rgba(0,0,0,0.3);
}
.add-to-reference-button {
color: white;
}
.add-to-reference-button:hover {
scale: 1.02 1.02 !important;
transform: scale(1.02) !important;
}
</style>
<div class=instructions>
<div class=additional_info>
<div><h4>{t[lang].preparation}</h4>
<p contenteditable type="text" bind:innerText={add_info.preparation}></p>
</div>
<div><h4>{t[lang].bulkFermentation}</h4>
<p contenteditable type="text" bind:innerText={add_info.fermentation.bulk}></p>
</div>
<div><h4>{t[lang].finalFermentation}</h4>
<p contenteditable type="text" bind:innerText={add_info.fermentation.final}></p>
</div>
<div><h4>{t[lang].baking}</h4>
<div><p type="text" bind:innerText={add_info.baking.length} contenteditable placeholder="40 min..."></p></div> bei <div><p type="text" bind:innerText={add_info.baking.temperature} contenteditable placeholder=200...></p></div> °C <div><p type="text" bind:innerText={add_info.baking.mode} contenteditable placeholder="Ober-/Unterhitze..."></p></div></div>
<div><h4>{t[lang].cooking}</h4>
<p contenteditable type="text" bind:innerText={add_info.cooking}></p>
</div>
<div><h4>{t[lang].totalTime}</h4>
<p contenteditable type="text" bind:innerText={add_info.total_time}></p>
</div>
</div>
<h2>{t[lang].instructions}</h2>
{#each instructions as list, list_index}
{#if list.type === 'reference'}
<!-- Reference item display -->
<div class="reference-container">
<div class="reference-header">
<div class="move_buttons_container">
<button type="button" onclick={() => update_list_position(list_index, 1)} aria-label={t[lang].moveReferenceUpAria}>
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button type="button" onclick={() => update_list_position(list_index, -1)} aria-label={t[lang].moveReferenceDownAria}>
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
<div class="reference-badge">
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
<div style="margin-top: 0.5em;">
<label style="font-size: 0.9em; display: flex; align-items: center; gap: 0.5em;">
{t[lang].baseMultiplier || 'Mengenfaktor'}:
<input
type="number"
bind:value={list.baseMultiplier}
min="0.1"
step="0.1"
style="width: 5em; padding: 0.25em 0.5em; border-radius: 5px; border: 1px solid var(--nord4);"
/>
</label>
</div>
</div>
<div class="mod_icons">
<button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
<Cross fill="var(--nord11)"></Cross>
</button>
</div>
</div>
<!-- Steps before base recipe -->
{#if list.stepsBefore && list.stepsBefore.length > 0}
<h4 style="margin-block: 0.5em; color: var(--nord9);">{t[lang].additionalStepsBefore}</h4>
<ol>
{#each list.stepsBefore as step, step_index}
<li>
<div style="display: flex; align-items: center;">
<div class="move_buttons_container step_move_buttons">
<!-- Empty for consistency -->
</div>
<button type="button" onclick={() => editStepFromReference(list_index, 'before', step_index)} class="step-button" style="flex-grow: 1;">
{@html step}
</button>
<div>
<button type="button" class="action_button button_subtle" onclick={() => editStepFromReference(list_index, 'before', step_index)} aria-label={t[lang].editStepAria}>
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
</button>
<button type="button" class="action_button button_subtle" onclick={() => removeStepFromReference(list_index, 'before', step_index)} aria-label={t[lang].removeStepAria}>
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
</button>
</div>
</div>
</li>
{/each}
</ol>
{/if}
<button type="button" class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'before')}>
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addStepBefore}
</button>
<!-- Base recipe content indicator -->
<div style="text-align: center; padding: 0.5em; margin: 0.5em 0; font-style: italic; color: var(--nord10); background-color: rgba(143, 188, 187, 0.4); border-radius: 5px;">
{t[lang].baseRecipeContent}
</div>
<!-- Steps after base recipe -->
<button type="button" class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'after')}>
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addStepAfter}
</button>
{#if list.stepsAfter && list.stepsAfter.length > 0}
<h4 style="margin-block: 0.5em; color: var(--nord9);">{t[lang].additionalStepsAfter}</h4>
<ol>
{#each list.stepsAfter as step, step_index}
<li>
<div style="display: flex; align-items: center;">
<div class="move_buttons_container step_move_buttons">
<!-- Empty for consistency -->
</div>
<button type="button" onclick={() => editStepFromReference(list_index, 'after', step_index)} class="step-button" style="flex-grow: 1;">
{@html step}
</button>
<div>
<button type="button" class="action_button button_subtle" onclick={() => editStepFromReference(list_index, 'after', step_index)} aria-label={t[lang].editStepAria}>
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
</button>
<button type="button" class="action_button button_subtle" onclick={() => removeStepFromReference(list_index, 'after', step_index)} aria-label={t[lang].removeStepAria}>
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
</button>
</div>
</div>
</li>
{/each}
</ol>
{/if}
</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<h3>
<div class=move_buttons_container>
<button type="button" onclick="{() => update_list_position(list_index, 1)}" aria-label={t[lang].moveListUpAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button type="button" onclick="{() => update_list_position(list_index, -1)}" aria-label={t[lang].moveListDownAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
<button type="button" onclick={() => show_modal_edit_subheading_step(list_index)} class="subheading-button">
{#if list.name}
{list.name}
{:else}
{t[lang].empty}
{/if}
</button>
<button type="button" class="action_button button_subtle" onclick="{() => show_modal_edit_subheading_step(list_index)}" aria-label={t[lang].editHeading}>
<Pen fill=var(--nord1)></Pen> </button>
<button type="button" class="action_button button_subtle" onclick="{() => remove_list(list_index)}" aria-label={t[lang].removeList}>
<Cross fill=var(--nord1)></Cross>
</button>
</h3>
<ol>
{#each list.steps as step, step_index}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li>
<div class="move_buttons_container step_move_buttons">
<button type="button" onclick="{() => update_step_position(list_index, step_index, 1)}" aria-label={t[lang].moveUpAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
</button>
<button type="button" onclick="{() => update_step_position(list_index, step_index, -1)}" aria-label={t[lang].moveDownAria}>
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
</button>
</div>
<div>
<button type="button" onclick={() => show_modal_edit_step(list_index, step_index)} class="step-button">
{@html step}
</button>
<div><button type="button" class="action_button button_subtle" onclick={() => show_modal_edit_step(list_index, step_index)} aria-label={t[lang].editStepAria}>
<Pen fill=var(--nord1)></Pen>
</button>
<button type="button" class="action_button button_subtle" onclick="{() => remove_step(list_index, step_index)}" aria-label={t[lang].removeStepAria}>
<Cross fill=var(--nord1)></Cross>
</button>
</div></div>
</li>
{/each}
</ol>
{/if}
{/each}
<!-- Button to insert base recipe -->
<button type="button" class="insert-base-recipe-button" onclick={() => openSelector(instructions.length)}>
<Plus fill="white" style="display: inline; width: 1.5em; height: 1.5em; vertical-align: middle;"></Plus>
{t[lang].insertBaseRecipe}
</button>
</div>
<div class='adder shadow'>
<input class=category type="text" bind:value={new_step.name} placeholder={t[lang].categoryOptional} onkeydown={(event) => do_on_key(event, 'Enter', false , add_new_step)} >
<div class=add_step>
<p id=step contenteditable onfocus='{clear_step}' onblur={add_placeholder} bind:innerText={new_step.step} onkeydown={(event) => do_on_key(event, 'Enter', true , add_new_step)}></p>
<button type="button" onclick={() => add_new_step()} class=action_button>
<Plus fill=white style="height: 2rem; width: 2rem"></Plus>
</button>
</div>
</div>
<dialog id="edit_step_modal-{lang}" oncancel={handleStepModalCancel}>
<h2>{t[lang].editStep}</h2>
<div class=adder>
<input class=category type="text" bind:value={edit_step.name} placeholder={t[lang].subcategoryOptional} onkeydown={(event) => do_on_key(event, 'Enter', false , edit_step_and_close_modal)}>
<div class=add_step>
<p id=step contenteditable bind:innerText={edit_step.step} onkeydown={(event) => do_on_key(event, 'Enter', true , edit_step_and_close_modal)}></p>
<button type="button" class=action_button onclick="{() => edit_step_and_close_modal()}" >
<Check fill=white style="height: 2rem; width: 2rem"></Check>
</button>
</div>
</div>
</dialog>
<dialog id="edit_subheading_steps_modal-{lang}">
<h2>{t[lang].renameCategory}</h2>
<div class=heading_wrapper>
<input class="heading" type="text" bind:value={edit_heading.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_steps_and_close_modal)}>
<button type="button" onclick={edit_subheading_steps_and_close_modal} class=action_button>
<Check fill=white style="height: 2rem; width: 2rem"></Check>
</button>
</div>
</dialog>
<!-- Base recipe selector -->
<BaseRecipeSelector
type="instructions"
onSelect={handleSelect}
bind:open={showSelector}
/>
@@ -0,0 +1,39 @@
<script lang="ts">
let { note = $bindable("") } = $props<{ note?: string }>();
</script>
<style>
div{
background-color: var(--red);
color: white;
padding: 1em;
font-size: 1.1rem;
max-width: 400px;
margin-inline: auto;
box-shadow: 0.2em 0.2em 0.5em 0.1em rgba(0, 0, 0, 0.3);
margin-bottom: 1em;
}
h3{
margin-block: 0;
}
textarea {
width: 100%;
min-height: 80px;
padding: 0.5em;
border-radius: 5px;
border: none;
color: white;
font-size: 1rem;
resize: vertical;
margin-top: 0.5em;
font-family: sans-serif;
background-color: transparent;
}
textarea::placeholder {
color: rgba(255, 255, 255, 0.6);
}
</style>
<div>
<h3>Notiz:</h3>
<textarea bind:value={note} placeholder="Füge eine Notiz für dieses Rezept hinzu..."></textarea>
</div>
@@ -0,0 +1,305 @@
<script lang="ts">
let {
ingredients = $bindable([]),
translationMetadata = null,
onchange
}: {
ingredients?: any[],
translationMetadata?: any[] | null | undefined,
onchange?: (detail: { ingredients: any[] }) => void
} = $props();
function handleChange() {
onchange?.({ ingredients });
}
function updateIngredientGroupName(groupIndex: number, event: Event) {
const target = event.target as HTMLInputElement;
ingredients[groupIndex].name = target.value;
handleChange();
}
function updateIngredientItem(groupIndex: number, itemIndex: number, field: string, event: Event) {
const target = event.target as HTMLInputElement;
ingredients[groupIndex].list[itemIndex][field] = target.value;
handleChange();
}
// Base recipe reference handlers
function updateLabelOverride(groupIndex: number, event: Event) {
const target = event.target as HTMLInputElement;
ingredients[groupIndex].labelOverride = target.value;
handleChange();
}
function updateItemBefore(groupIndex: number, itemIndex: number, field: string, event: Event) {
const target = event.target as HTMLInputElement;
if (!ingredients[groupIndex].itemsBefore) {
ingredients[groupIndex].itemsBefore = [];
}
ingredients[groupIndex].itemsBefore[itemIndex][field] = target.value;
handleChange();
}
function updateItemAfter(groupIndex: number, itemIndex: number, field: string, event: Event) {
const target = event.target as HTMLInputElement;
if (!ingredients[groupIndex].itemsAfter) {
ingredients[groupIndex].itemsAfter = [];
}
ingredients[groupIndex].itemsAfter[itemIndex][field] = target.value;
handleChange();
}
// Check if a group name was re-translated
function isGroupNameTranslated(groupIndex: number): boolean {
return translationMetadata?.[groupIndex]?.nameTranslated ?? false;
}
// Check if a specific item was re-translated
function isItemTranslated(groupIndex: number, itemIndex: number): boolean {
return translationMetadata?.[groupIndex]?.itemsTranslated?.[itemIndex] ?? false;
}
</script>
<style>
.ingredients-editor {
background: var(--nord0);
border: 1px solid var(--nord3);
border-radius: 4px;
padding: 0.75rem;
}
@media(prefers-color-scheme: light) {
.ingredients-editor {
background: var(--nord5);
border-color: var(--nord3);
}
}
.ingredient-group {
margin-bottom: 1.5rem;
}
.ingredient-group:last-child {
margin-bottom: 0;
}
.group-name {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--nord1);
border: 1px solid var(--nord3);
border-radius: 4px;
color: var(--nord6);
font-weight: 600;
font-size: 0.95rem;
}
@media(prefers-color-scheme: light) {
.group-name {
background: var(--nord6);
color: var(--nord0);
}
}
.ingredient-item {
display: grid;
grid-template-columns: 60px 60px 1fr;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.ingredient-item input {
padding: 0.4rem;
background: var(--nord1);
border: 1px solid var(--nord3);
border-radius: 4px;
color: var(--nord6);
font-size: 0.9rem;
}
@media(prefers-color-scheme: light) {
.ingredient-item input {
background: var(--nord6);
color: var(--nord0);
}
}
.ingredient-item input:focus {
outline: 2px solid var(--nord14);
border-color: var(--nord14);
}
.ingredient-item input.amount {
text-align: right;
}
/* Highlight re-translated items with red border */
.retranslated {
border: 2px solid var(--nord11) !important;
animation: highlight-flash 0.6s ease-out;
}
@keyframes highlight-flash {
0% {
box-shadow: 0 0 10px var(--nord11);
}
100% {
box-shadow: 0 0 0 transparent;
}
}
.reference-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--nord9);
color: var(--nord6);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.reference-section {
padding: 0.5rem;
background: var(--nord2);
border-radius: 4px;
margin-bottom: 0.5rem;
}
@media(prefers-color-scheme: light) {
.reference-section {
background: var(--nord4);
}
}
.reference-section-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--nord8);
margin-bottom: 0.25rem;
}
</style>
<div class="ingredients-editor">
{#each ingredients as group, groupIndex}
<div class="ingredient-group">
{#if group.type === 'reference'}
<span class="reference-badge">🔗 Base Recipe Reference</span>
{#if group.labelOverride !== undefined}
<input
type="text"
class="group-name"
value={group.labelOverride || ''}
on:input={(e) => updateLabelOverride(groupIndex, e)}
placeholder="Label override (optional)"
/>
{/if}
{#if group.itemsBefore && group.itemsBefore.length > 0}
<div class="reference-section">
<div class="reference-section-label">Items Before Base Recipe:</div>
{#each group.itemsBefore as item, itemIndex}
<div class="ingredient-item">
<input
type="text"
class="amount"
value={item.amount || ''}
on:input={(e) => updateItemBefore(groupIndex, itemIndex, 'amount', e)}
placeholder="Amt"
/>
<input
type="text"
class="unit"
class:retranslated={isItemTranslated(groupIndex, itemIndex)}
value={item.unit || ''}
on:input={(e) => updateItemBefore(groupIndex, itemIndex, 'unit', e)}
placeholder="Unit"
/>
<input
type="text"
class="name"
class:retranslated={isItemTranslated(groupIndex, itemIndex)}
value={item.name || ''}
on:input={(e) => updateItemBefore(groupIndex, itemIndex, 'name', e)}
placeholder="Ingredient name"
/>
</div>
{/each}
</div>
{/if}
{#if group.itemsAfter && group.itemsAfter.length > 0}
<div class="reference-section">
<div class="reference-section-label">Items After Base Recipe:</div>
{#each group.itemsAfter as item, itemIndex}
<div class="ingredient-item">
<input
type="text"
class="amount"
value={item.amount || ''}
on:input={(e) => updateItemAfter(groupIndex, itemIndex, 'amount', e)}
placeholder="Amt"
/>
<input
type="text"
class="unit"
class:retranslated={isItemTranslated(groupIndex, itemIndex)}
value={item.unit || ''}
on:input={(e) => updateItemAfter(groupIndex, itemIndex, 'unit', e)}
placeholder="Unit"
/>
<input
type="text"
class="name"
class:retranslated={isItemTranslated(groupIndex, itemIndex)}
value={item.name || ''}
on:input={(e) => updateItemAfter(groupIndex, itemIndex, 'name', e)}
placeholder="Ingredient name"
/>
</div>
{/each}
</div>
{/if}
{:else}
<input
type="text"
class="group-name"
class:retranslated={isGroupNameTranslated(groupIndex)}
value={group.name || ''}
on:input={(e) => updateIngredientGroupName(groupIndex, e)}
placeholder="Ingredient group name"
/>
{#each group.list as item, itemIndex}
<div class="ingredient-item">
<input
type="text"
class="amount"
value={item.amount || ''}
on:input={(e) => updateIngredientItem(groupIndex, itemIndex, 'amount', e)}
placeholder="Amt"
/>
<input
type="text"
class="unit"
class:retranslated={isItemTranslated(groupIndex, itemIndex)}
value={item.unit || ''}
on:input={(e) => updateIngredientItem(groupIndex, itemIndex, 'unit', e)}
placeholder="Unit"
/>
<input
type="text"
class="name"
class:retranslated={isItemTranslated(groupIndex, itemIndex)}
value={item.name || ''}
on:input={(e) => updateIngredientItem(groupIndex, itemIndex, 'name', e)}
placeholder="Ingredient name"
/>
</div>
{/each}
{/if}
</div>
{/each}
</div>
@@ -0,0 +1,275 @@
<script lang="ts">
let {
instructions = $bindable([]),
translationMetadata = null,
onchange
}: {
instructions?: any[],
translationMetadata?: any[] | null | undefined,
onchange?: (detail: { instructions: any[] }) => void
} = $props();
function handleChange() {
onchange?.({ instructions });
}
function updateInstructionGroupName(groupIndex: number, event: Event) {
const target = event.target as HTMLInputElement;
instructions[groupIndex].name = target.value;
handleChange();
}
function updateStep(groupIndex: number, stepIndex: number, event: Event) {
const target = event.target as HTMLTextAreaElement;
instructions[groupIndex].steps[stepIndex] = target.value;
handleChange();
}
// Base recipe reference handlers
function updateLabelOverride(groupIndex: number, event: Event) {
const target = event.target as HTMLInputElement;
instructions[groupIndex].labelOverride = target.value;
handleChange();
}
function updateStepBefore(groupIndex: number, stepIndex: number, event: Event) {
const target = event.target as HTMLTextAreaElement;
if (!instructions[groupIndex].stepsBefore) {
instructions[groupIndex].stepsBefore = [];
}
instructions[groupIndex].stepsBefore[stepIndex] = target.value;
handleChange();
}
function updateStepAfter(groupIndex: number, stepIndex: number, event: Event) {
const target = event.target as HTMLTextAreaElement;
if (!instructions[groupIndex].stepsAfter) {
instructions[groupIndex].stepsAfter = [];
}
instructions[groupIndex].stepsAfter[stepIndex] = target.value;
handleChange();
}
// Check if a group name was re-translated
function isGroupNameTranslated(groupIndex: number): boolean {
return translationMetadata?.[groupIndex]?.nameTranslated ?? false;
}
// Check if a specific step was re-translated
function isStepTranslated(groupIndex: number, stepIndex: number): boolean {
return translationMetadata?.[groupIndex]?.stepsTranslated?.[stepIndex] ?? false;
}
</script>
<style>
.instructions-editor {
background: var(--nord0);
border: 1px solid var(--nord3);
border-radius: 4px;
padding: 0.75rem;
}
@media(prefers-color-scheme: light) {
.instructions-editor {
background: var(--nord5);
border-color: var(--nord3);
}
}
.instruction-group {
margin-bottom: 1.5rem;
}
.instruction-group:last-child {
margin-bottom: 0;
}
.group-name {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--nord1);
border: 1px solid var(--nord3);
border-radius: 4px;
color: var(--nord6);
font-weight: 600;
font-size: 0.95rem;
}
@media(prefers-color-scheme: light) {
.group-name {
background: var(--nord6);
color: var(--nord0);
}
}
.step-item {
margin-bottom: 0.75rem;
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.step-number {
min-width: 2rem;
padding: 0.4rem 0.5rem;
background: var(--nord3);
border-radius: 4px;
text-align: center;
color: var(--nord6);
font-weight: 600;
font-size: 0.9rem;
}
@media(prefers-color-scheme: light) {
.step-number {
background: var(--nord4);
color: var(--nord0);
}
}
.step-item textarea {
flex: 1;
padding: 0.5rem;
background: var(--nord1);
border: 1px solid var(--nord3);
border-radius: 4px;
color: var(--nord6);
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
min-height: 3rem;
}
@media(prefers-color-scheme: light) {
.step-item textarea {
background: var(--nord6);
color: var(--nord0);
}
}
.step-item textarea:focus {
outline: 2px solid var(--nord14);
border-color: var(--nord14);
}
/* Highlight re-translated items with red border */
.retranslated {
border: 2px solid var(--nord11) !important;
animation: highlight-flash 0.6s ease-out;
}
@keyframes highlight-flash {
0% {
box-shadow: 0 0 10px var(--nord11);
}
100% {
box-shadow: 0 0 0 transparent;
}
}
.reference-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: var(--nord9);
color: var(--nord6);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.reference-section {
padding: 0.5rem;
background: var(--nord2);
border-radius: 4px;
margin-bottom: 0.5rem;
}
@media(prefers-color-scheme: light) {
.reference-section {
background: var(--nord4);
}
}
.reference-section-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--nord8);
margin-bottom: 0.25rem;
}
</style>
<div class="instructions-editor">
{#each instructions as group, groupIndex}
<div class="instruction-group">
{#if group.type === 'reference'}
<span class="reference-badge">🔗 Base Recipe Reference</span>
{#if group.labelOverride !== undefined}
<input
type="text"
class="group-name"
value={group.labelOverride || ''}
on:input={(e) => updateLabelOverride(groupIndex, e)}
placeholder="Label override (optional)"
/>
{/if}
{#if group.stepsBefore && group.stepsBefore.length > 0}
<div class="reference-section">
<div class="reference-section-label">Steps Before Base Recipe:</div>
{#each group.stepsBefore as step, stepIndex}
<div class="step-item">
<div class="step-number">{stepIndex + 1}</div>
<textarea
class:retranslated={isStepTranslated(groupIndex, stepIndex)}
value={step || ''}
on:input={(e) => updateStepBefore(groupIndex, stepIndex, e)}
placeholder="Step description"
/>
</div>
{/each}
</div>
{/if}
{#if group.stepsAfter && group.stepsAfter.length > 0}
<div class="reference-section">
<div class="reference-section-label">Steps After Base Recipe:</div>
{#each group.stepsAfter as step, stepIndex}
<div class="step-item">
<div class="step-number">{stepIndex + 1}</div>
<textarea
class:retranslated={isStepTranslated(groupIndex, stepIndex)}
value={step || ''}
on:input={(e) => updateStepAfter(groupIndex, stepIndex, e)}
placeholder="Step description"
/>
</div>
{/each}
</div>
{/if}
{:else}
<input
type="text"
class="group-name"
class:retranslated={isGroupNameTranslated(groupIndex)}
value={group.name || ''}
on:input={(e) => updateInstructionGroupName(groupIndex, e)}
placeholder="Instruction section name"
/>
{#each group.steps as step, stepIndex}
<div class="step-item">
<div class="step-number">{stepIndex + 1}</div>
<textarea
class:retranslated={isStepTranslated(groupIndex, stepIndex)}
value={step || ''}
on:input={(e) => updateStep(groupIndex, stepIndex, e)}
placeholder="Step description"
/>
</div>
{/each}
{/if}
</div>
{/each}
</div>
@@ -0,0 +1,73 @@
<script>
import "$lib/css/nordtheme.css";
import Toggle from '$lib/components/Toggle.svelte';
let {
enabled = false,
onToggle = () => {},
isLoggedIn = false,
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Favorites' : 'Favoriten');
let checked = $state(enabled);
// Watch for changes to checked and call onToggle
$effect(() => {
// Track checked as dependency
const currentChecked = checked;
// Call onToggle whenever checked changes
onToggle(currentChecked);
});
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-width: 100%;
align-items: center;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
align-items: flex-start;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord2);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.filter-label {
color: var(--nord6);
}
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<Toggle
bind:checked={checked}
label=""
/>
</div>
@@ -0,0 +1,186 @@
<script>
import "$lib/css/nordtheme.css";
import CategoryFilter from './CategoryFilter.svelte';
import TagFilter from './TagFilter.svelte';
import IconFilter from './IconFilter.svelte';
import SeasonFilter from './SeasonFilter.svelte';
import FavoritesFilter from './FavoritesFilter.svelte';
import LogicModeToggle from './LogicModeToggle.svelte';
let {
availableCategories = [],
availableTags = [],
availableIcons = [],
selectedCategory = null,
selectedTags = [],
selectedIcon = null,
selectedSeasons = [],
selectedFavoritesOnly = false,
useAndLogic = true,
lang = 'de',
isLoggedIn = false,
hideFavoritesFilter = false,
onCategoryChange = () => {},
onTagToggle = () => {},
onIconChange = () => {},
onSeasonChange = () => {},
onFavoritesToggle = () => {},
onLogicModeToggle = () => {}
} = $props();
const isEnglish = $derived(lang === 'en');
const months = $derived(isEnglish
? ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
);
let filtersOpen = $state(false);
function toggleFilters() {
filtersOpen = !filtersOpen;
}
</script>
<style>
.filter-wrapper {
width: 900px;
max-width: 95vw;
margin: 1rem auto 2rem;
}
.toggle-button {
display: none;
background: transparent;
color: var(--nord3);
padding: 0.5rem 0.8rem;
border: 1px solid var(--nord2);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 150ms ease;
margin: 0 auto 1rem;
max-width: 200px;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.toggle-button:hover {
background: var(--nord1);
color: var(--nord4);
border-color: var(--nord3);
}
.arrow {
transition: transform 150ms ease;
font-size: 1rem;
}
.arrow.open {
transform: rotate(180deg);
}
.filter-panel {
display: grid;
gap: 2rem;
align-items: start;
}
.filter-panel.with-favorites {
grid-template-columns: 110px 120px 120px 1fr 160px 90px;
}
.filter-panel.without-favorites {
grid-template-columns: 110px 120px 120px 1fr 160px;
}
@media (max-width: 968px) {
.toggle-button {
display: flex;
}
.filter-panel.with-favorites,
.filter-panel.without-favorites {
grid-template-columns: 1fr;
gap: 1rem;
max-width: 600px;
margin: 0 auto;
transition: all 200ms ease;
}
.filter-panel:not(.open) {
display: none;
}
.filter-panel.open {
display: grid;
animation: slideDown 200ms ease;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
<div class="filter-wrapper">
<button class="toggle-button" onclick={toggleFilters} type="button">
<span>{filtersOpen ? (isEnglish ? 'Hide Filters' : 'Filter ausblenden') : (isEnglish ? 'Show Filters' : 'Filter einblenden')}</span>
<span class="arrow" class:open={filtersOpen}>▼</span>
</button>
<div class="filter-panel" class:open={filtersOpen} class:with-favorites={isLoggedIn && !hideFavoritesFilter} class:without-favorites={!isLoggedIn || hideFavoritesFilter}>
<LogicModeToggle
{useAndLogic}
onToggle={onLogicModeToggle}
{lang}
/>
<CategoryFilter
categories={availableCategories}
selected={selectedCategory}
onChange={onCategoryChange}
{lang}
{useAndLogic}
/>
<IconFilter
{availableIcons}
selected={selectedIcon}
onChange={onIconChange}
{lang}
{useAndLogic}
/>
<TagFilter
{availableTags}
{selectedTags}
onToggle={onTagToggle}
{lang}
/>
<SeasonFilter
{selectedSeasons}
onChange={onSeasonChange}
{lang}
{months}
/>
{#if isLoggedIn && !hideFavoritesFilter}
<FavoritesFilter
enabled={selectedFavoritesOnly}
onToggle={onFavoritesToggle}
{isLoggedIn}
{lang}
/>
{/if}
</div>
</div>
@@ -0,0 +1,92 @@
<script lang="ts">
let { shortName, imageIndex } = $props<{ shortName: string; imageIndex: number }>();
let loading = $state(false);
let error = $state('');
let success = $state('');
async function generateAltText() {
loading = true;
error = '';
success = '';
try {
const response = await fetch('/api/generate-alt-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
shortName,
imageIndex,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to generate alt text');
}
success = `Generated: DE: "${data.altText.de}" | EN: "${data.altText.en}"`;
// Reload page to show updated alt text
setTimeout(() => {
window.location.reload();
}, 2000);
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred';
} finally {
loading = false;
}
}
</script>
<style>
button {
padding: 0.5rem 1rem;
background-color: var(--nord8);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
button:hover {
background-color: var(--nord7);
}
button:disabled {
background-color: var(--nord3);
cursor: not-allowed;
}
.message {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.success {
background-color: var(--nord14);
color: var(--nord0);
}
.error {
background-color: var(--nord11);
color: white;
}
</style>
<button onclick={generateAltText} disabled={loading}>
{loading ? '🤖 Generating...' : '✨ Generate Alt Text (AI)'}
</button>
{#if success}
<div class="message success">{success}</div>
{/if}
{#if error}
<div class="message error">{error}</div>
{/if}
@@ -0,0 +1,60 @@
<script>
import { browser } from '$app/environment';
import { enhance } from '$app/forms';
import { page } from '$app/stores';
let { item, multiplier = 1, yeastId = 0, lang = 'de' } = $props();
const isEnglish = $derived(lang === 'en');
const toggleTitle = $derived(isEnglish
? 'Switch between fresh yeast and dry yeast'
: 'Zwischen Frischhefe und Trockenhefe wechseln');
// Get all current URL parameters to preserve state
const currentParams = $derived(browser ? new URLSearchParams(window.location.search) : $page.url.searchParams);
function toggleHefe(event) {
// If JavaScript is available, prevent form submission and handle client-side
if (browser) {
event.preventDefault();
// Simply toggle the yeast flag in the URL
const url = new URL(window.location);
const yeastParam = `y${yeastId}`;
if (url.searchParams.has(yeastParam)) {
url.searchParams.delete(yeastParam);
} else {
url.searchParams.set(yeastParam, '1');
}
window.history.replaceState({}, '', url);
// Trigger page reload to recalculate ingredients server-side
window.location.reload();
}
// If no JS, form will submit normally
}
</script>
<style>
button{
background: none;
border: none;
cursor: pointer;
}
svg{
width: 1.1em;
height: 1.1em;
fill: var(--blue);
}
</style>
<form method="post" action="?/swapYeast" style="display: inline;" use:enhance>
<input type="hidden" name="yeastId" value={yeastId} />
<!-- Include all current URL parameters to preserve state -->
{#each Array.from(currentParams.entries()) as [key, value]}
<input type="hidden" name="currentParam_{key}" value={value} />
{/each}
<button type="submit" onclick={toggleHefe} title={toggleTitle}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M105.1 202.6c7.7-21.8 20.2-42.3 37.8-59.8c62.5-62.5 163.8-62.5 226.3 0L386.3 160 352 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l111.5 0c0 0 0 0 0 0l.4 0c17.7 0 32-14.3 32-32l0-112c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 35.2L414.4 97.6c-87.5-87.5-229.3-87.5-316.8 0C73.2 122 55.6 150.7 44.8 181.4c-5.9 16.7 2.9 34.9 19.5 40.8s34.9-2.9 40.8-19.5zM39 289.3c-5 1.5-9.8 4.2-13.7 8.2c-4 4-6.7 8.8-8.1 14c-.3 1.2-.6 2.5-.8 3.8c-.3 1.7-.4 3.4-.4 5.1L16 432c0 17.7 14.3 32 32 32s32-14.3 32-32l0-35.1 17.6 17.5c0 0 0 0 0 0c87.5 87.4 229.3 87.4 316.7 0c24.4-24.4 42.1-53.1 52.9-83.8c5.9-16.7-2.9-34.9-19.5-40.8s-34.9 2.9-40.8 19.5c-7.7 21.8-20.2 42.3-37.8 59.8c-62.5 62.5-163.8 62.5-226.3 0l-.1-.1L125.6 352l34.4 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L48.4 288c-1.6 0-3.2 .1-4.8 .3s-3.1 .5-4.6 1z"/></svg>
</button>
</form>
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import '$lib/css/nordtheme.css';
import "$lib/css/shake.css"
let { icon, ...restProps } = $props<{ icon: string, [key: string]: any }>();
</script>
<style>
a{
font-family: "Noto Color Emoji", emoji;
font-size: 2rem;
text-decoration: none;
padding: 0.5em;
background-color: var(--nord4);
border-radius: 1000px;
box-shadow: 0em 0em 0.5em 0.2em rgba(0, 0, 0, 0.2);
}
@media (prefers-color-scheme: dark) {
a{
background-color: var(--accent-dark);
}
}
a:hover{
--angle: 15deg;
animation: shake 0.5s ease forwards;
}
</style>
<a href="/rezepte/icon/{icon}" {...restProps} >{icon}</a>
@@ -0,0 +1,232 @@
<script>
import "$lib/css/nordtheme.css";
import TagChip from '$lib/components/recipes/TagChip.svelte';
let {
availableIcons = [],
selected = null,
onChange = () => {},
lang = 'de',
useAndLogic = true
} = $props();
const isEnglish = $derived(lang === 'en');
const label = 'Icon';
const selectLabel = $derived(isEnglish ? 'Select icon...' : 'Icon auswählen...');
// Convert selected to array for OR mode, keep as single value for AND mode
const selectedArray = $derived(
useAndLogic
? (selected ? [selected] : [])
: (Array.isArray(selected) ? selected : (selected ? [selected] : []))
);
let inputValue = $state('');
let dropdownOpen = $state(false);
// Filter icons based on input (though input for emoji is uncommon)
const filteredIcons = $derived(
inputValue.trim() === ''
? availableIcons
: availableIcons.filter(icon =>
icon.includes(inputValue.trim())
)
);
function handleInputFocus() {
dropdownOpen = true;
}
function handleInputBlur() {
setTimeout(() => {
dropdownOpen = false;
inputValue = '';
}, 200);
}
function handleIconSelect(icon) {
if (useAndLogic) {
// AND mode: single select
onChange(icon);
inputValue = '';
dropdownOpen = false;
} else {
// OR mode: multi-select toggle
const currentSelected = Array.isArray(selected) ? selected : (selected ? [selected] : []);
if (currentSelected.includes(icon)) {
const newSelected = currentSelected.filter(i => i !== icon);
onChange(newSelected.length > 0 ? newSelected : null);
} else {
onChange([...currentSelected, icon]);
}
inputValue = '';
}
}
function handleKeyDown(event) {
if (event.key === 'Escape') {
dropdownOpen = false;
inputValue = '';
}
}
function handleRemove(icon) {
if (useAndLogic) {
onChange(null);
} else {
const currentSelected = Array.isArray(selected) ? selected : (selected ? [selected] : []);
const newSelected = currentSelected.filter(i => i !== icon);
onChange(newSelected.length > 0 ? newSelected : null);
}
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
max-width: 100%;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord2);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.filter-label {
color: var(--nord6);
}
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.input-wrapper {
position: relative;
}
input {
all: unset;
box-sizing: border-box;
font-family: "Noto Color Emoji", emoji, sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
border-radius: 6px;
width: 100%;
transition: all 100ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.9rem;
}
@media (max-width: 968px) {
input {
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
}
input::placeholder {
color: var(--nord4);
font-family: sans-serif;
}
input:hover {
background: var(--nord2);
}
input:focus-visible {
outline: 2px solid var(--nord10);
outline-offset: 2px;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.3rem;
background: var(--nord0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.3));
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.selected-icon {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<!-- Input with custom dropdown -->
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeyDown}
placeholder={selectLabel}
autocomplete="off"
/>
<!-- Custom dropdown with icon chips -->
{#if dropdownOpen && filteredIcons.length > 0}
<div class="dropdown">
{#each filteredIcons as icon}
<TagChip
tag={icon}
selected={selectedArray.includes(icon)}
removable={false}
onToggle={() => handleIconSelect(icon)}
/>
{/each}
</div>
{/if}
</div>
<!-- Selected icons display below -->
{#if selectedArray.length > 0}
<div class="selected-icon">
{#each selectedArray as icon}
<TagChip
tag={icon}
selected={true}
removable={true}
onToggle={() => handleRemove(icon)}
/>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,98 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import '$lib/css/nordtheme.css';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import Search from './Search.svelte';
let {
icons,
active_icon,
routePrefix = '/rezepte',
lang = 'de',
recipes = [],
isLoggedIn = false,
onSearchResults = (ids, categories) => {},
recipesSlot
}: {
icons: string[],
active_icon: string,
routePrefix?: string,
lang?: string,
recipes?: any[],
isLoggedIn?: boolean,
onSearchResults?: (ids: any[], categories: any[]) => void,
recipesSlot?: Snippet
} = $props();
</script>
<style>
a{
font-family: "Noto Color Emoji", emoji, sans-serif;
font-size: 2rem;
text-decoration: none;
padding: 0.5em;
background-color: var(--nord4);
border-radius: 1000px;
box-shadow: 0em 0em 0.5em 0.2em rgba(0, 0, 0, 0.2);
}
a:hover,
a:focus-visible,
.active
{
--angle: 15deg;
animation: shake 0.5s ease forwards;
background-color: var(--nord2);
}
.flex{
display:flex;
flex-wrap:wrap;
gap: 1rem;
max-width: 1000px;
justify-content: center;
margin:4rem auto;
}
@keyframes shake{
0%{
transform: rotate(0)
scale(1,1);
}
25%{
box-shadow: 0em 0em 0.6em 0.3em rgba(0, 0, 0, 0.2);
transform: rotate(var(--angle))
scale(1.2,1.2)
;
}
50%{
box-shadow: 0em 0em 0.6em 0.3em rgba(0, 0, 0, 0.2);
transform: rotate(calc(-1* var(--angle)))
scale(1.2,1.2);
}
74%{
box-shadow: 0em 0em 0.6em 0.3em rgba(0, 0, 0, 0.2);
transform: rotate(var(--angle))
scale(1.2, 1.2);
}
100%{
transform: rotate(0)
scale(1.2,1.2);
box-shadow: 0em 0em 0.6em 0.3em rgba(0, 0, 0, 0.2);
}
}
</style>
<div class=flex>
{#each icons as icon, i}
<a class:active={active_icon == icon} href="{routePrefix}/icon/{icon}">{icon}</a>
{/each}
</div>
<section>
<Search icon={active_icon} {lang} {recipes} {isLoggedIn} {onSearchResults}></Search>
</section>
<section>
{@render recipesSlot?.()}
</section>
@@ -0,0 +1,564 @@
<script lang='ts'>
import { onMount } from 'svelte';
import Pen from '$lib/assets/icons/Pen.svelte'
import Cross from '$lib/assets/icons/Cross.svelte'
import Plus from '$lib/assets/icons/Plus.svelte'
import Check from '$lib/assets/icons/Check.svelte'
import "$lib/css/action_button.css"
let { list = $bindable(), list_index } = $props<{ list: any, list_index: number }>();
let edit_ingredient = $state({
amount: "",
unit: "",
name: "",
sublist: "",
list_index: "",
ingredient_index: "",
});
let edit_heading = $state({
name:"",
list_index: "",
});
function get_sublist_index(sublist_name, list){
for(var i =0; i < list.length; i++){
if(list[i].name == sublist_name){
return i
}
}
return -1
}
export function show_modal_edit_subheading_ingredient(list_index){
edit_heading.name = ingredients[list_index].name
edit_heading.list_index = list_index
const el = document.querySelector('#edit_subheading_ingredient_modal')
el.showModal()
}
export function edit_subheading_and_close_modal(){
ingredients[edit_heading.list_index].name = edit_heading.name
const el = document.querySelector('#edit_subheading_ingredient_modal')
el.close()
}
export function add_new_ingredient(){
if(!new_ingredient.name){
return
}
let list_index = get_sublist_index(new_ingredient.sublist, ingredients)
if(list_index == -1){
ingredients.push({
name: new_ingredient.sublist,
list: [],
})
list_index = ingredients.length - 1
}
ingredients[list_index].list.push({ ...new_ingredient})
ingredients = ingredients //tells svelte to update dom
}
export function remove_list(list_index){
if(ingredients[list_index].list.length > 1){
const response = confirm("Bist du dir sicher, dass du diese Liste löschen möchtest? Alle Zutaten der Liste werden hiermit auch gelöscht.");
if(!response){
return
}
}
ingredients.splice(list_index, 1);
ingredients = ingredients //tells svelte to update dom
}
export function remove_ingredient(list_index, ingredient_index){
ingredients[list_index].list.splice(ingredient_index, 1)
ingredients = ingredients //tells svelte to update dom
}
export function show_modal_edit_ingredient(list_index, ingredient_index){
edit_ingredient = {...ingredients[list_index].list[ingredient_index]}
edit_ingredient.list_index = list_index
edit_ingredient.ingredient_index = ingredient_index
edit_ingredient.sublist = ingredients[list_index].name
const modal_el = document.querySelector("#edit_ingredient_modal");
modal_el.showModal();
}
export function edit_ingredient_and_close_modal(){
ingredients[edit_ingredient.list_index].list[edit_ingredient.ingredient_index] = {
amount: edit_ingredient.amount,
unit: edit_ingredient.unit,
name: edit_ingredient.name,
}
ingredients[edit_ingredient.list_index].name = edit_ingredient.sublist
const modal_el = document.querySelector("#edit_ingredient_modal");
modal_el.close();
}
let ghost;
let grabbed;
let lastTarget;
let mouseY = 0; // pointer y coordinate within client
let offsetY = 0; // y distance from top of grabbed element to pointer
let layerY = 0; // distance from top of list to top of client
function grab(clientY, element) {
// modify grabbed element
grabbed = element;
grabbed.dataset.grabY = clientY;
// modify ghost element (which is actually dragged)
ghost.innerHTML = grabbed.innerHTML;
// record offset from cursor to top of element
// (used for positioning ghost)
offsetY = grabbed.getBoundingClientRect().y - clientY;
drag(clientY);
}
// drag handler updates cursor position
function drag(clientY) {
if (grabbed) {
mouseY = clientY;
layerY = ghost.parentNode.getBoundingClientRect().y;
}
}
// touchEnter handler emulates the mouseenter event for touch input
// (more or less)
function touchEnter(ev) {
drag(ev.clientY);
// trigger dragEnter the first time the cursor moves over a list item
let target = document.elementFromPoint(ev.clientX, ev.clientY).closest(".item");
if (target && target != lastTarget) {
lastTarget = target;
dragEnter(ev, target);
}
}
function dragEnter(ev, target) {
// swap items in data
if (grabbed && target != grabbed && target.classList.contains("item")) {
moveDatum(parseInt(grabbed.dataset.index), parseInt(target.dataset.index));
}
}
// does the actual moving of items in data
function moveDatum(from, to) {
let temp = list[0].list[from];
list[0].list = [...list[0].list.slice(0, from), ...list[0].list.slice(from + 1)];
list[0].list= [...list[0].list.slice(0, to), temp, ...list[0].list.slice(to)];
}
function release(ev) {
grabbed = null;
}
function removeDatum(index) {
list= [...list.slice(0, index), ...list.slice(index + 1)];
}
</script>
<style>
input::placeholder{
color: inherit;
}
.drag_handle{
cursor: grab;
display:flex;
justify-content: flex-start;
align-items: center;
}
.drag_handle_header{
padding-right: 0.5em;
}
input{
color: unset;
font-size: unset;
padding: unset;
background-color: unset;
}
input.heading{
all: unset;
box-sizing: border-box;
background-color: var(--nord0);
padding: 1rem;
padding-inline: 2rem;
font-size: 1.5rem;
width: 100%;
border-radius: 1000px;
color: white;
justify-content: center;
align-items: center;
transition: 200ms;
}
input.heading:hover{
background-color: var(--nord1);
}
.heading_wrapper{
position: relative;
width: 300px;
margin-inline: auto;
transition: 200ms;
}
.heading_wrapper:hover
{
transform:scale(1.1,1.1);
}
.heading_wrapper button{
position: absolute;
bottom: -1.5rem;
right: -2rem;
}
.adder{
box-sizing: border-box;
margin-inline: auto;
position: relative;
margin-block: 3rem;
width: 90%;
border-radius: 20px;
transition: 200ms;
}
.adder button{
position: absolute;
right: -1.5rem;
bottom: -1.5rem;
}
.category{
border: none;
position: absolute;
--font_size: 1.5rem;
top: -1em;
left: -1em;
font-family: sans-serif;
font-size: 1.5rem;
background-color: var(--nord0);
color: var(--nord4);
border-radius: 1000000px;
width: 23ch;
padding: 0.5em 1em;
transition: 100ms;
box-shadow: 0.5em 0.5em 1em 0.4em rgba(0,0,0,0.3);
}
.category:hover{
background-color: var(--nord1);
transform: scale(1.05,1.05);
}
.adder:hover,
.adder:focus-within
{
transform: scale(1.05, 1.05);
}
.add_ingredient{
font-family: sans-serif;
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
align-items: center;
font-size: 1.2rem;
padding: 2rem;
padding-top: 2.5rem;
border-radius: 20px;
background-color: var(--blue);
color: #bbb;
transition: 200ms;
gap: 0.5rem;
}
.add_ingredient input{
border: 2px solid var(--nord4);
color: var(--nord4);
border-radius: 1000px;
padding: 0.5em 1em;
transition: 100ms;
}
.add_ingredient input:hover,
.add_ingredient input:focus-visible
{
border-color: white;
color: white;
transform: scale(1.02, 1.02);
}
.add_ingredient input:nth-of-type(1){
max-width: 8ch;
}
.add_ingredient input:nth-of-type(2){
max-width: 8ch;
}
.add_ingredient input:nth-of-type(3){
max-width: 30ch;
}
dialog{
box-sizing: content-box;
width: 100%;
height: 100%;
background-color: transparent;
border: unset;
margin: 0;
transition: 500ms;
}
dialog[open]::backdrop{
animation: show 200ms ease forwards;
}
@keyframes show{
from {
backdrop-filter: blur(0px);
}
to {
backdrop-filter: blur(10px);
}
}
dialog .adder{
margin-top: 5rem;
}
dialog h2{
font-size: 3rem;
font-family: sans-serif;
color: white;
text-align: center;
margin-top: 30vh;
margin-top: 30dvh;
filter: drop-shadow(0 0 0.4em black)
drop-shadow(0 0 1em black)
;
}
.mod_icons{
display: flex;
flex-direction: row;
margin-left: 2rem;
}
.button_subtle{
padding: 0em;
animation: unset;
margin: 0.2em 0.1em;
background-color: transparent;
box-shadow: unset;
}
.button_subtle:hover{
scale: 1.2 1.2;
}
h3{
width: fit-content;
display: flex;
flex-direction: row;
max-width: 1000px;
justify-content: space-between;
user-select: none;
cursor: pointer;
}
.ingredients_grid > span{
box-sizing: border-box;
display: grid;
font-size: 1.1em;
grid-template-columns: 1em 2fr 3fr 2em;
grid-template-rows: auto;
grid-auto-flow: row;
align-items: center;
row-gap: 0.5em;
column-gap: 0.5em;
}
.ingredients_grid > *{
cursor: pointer;
user-select: none;
}
.ingredients_grid>*:nth-child(3n+1){
min-width: 5ch;
}
.list_wrapper{
padding-inline: 2em;
padding-block: 1em;
}
.list_wrapper p[contenteditable]{
border: 2px solid grey;
border-radius: 1000px;
padding: 0.25em 1em;
background-color: white;
transition: 200ms;
}
@media screen and (max-width: 500px){
dialog h2{
margin-top: 2rem;
}
dialog .heading_wrapper{
width: 80%;
}
.ingredients_grid .mod_icons{
margin-left: 0;
}
}
.list {
cursor: grab;
z-index: 5;
display: flex;
flex-direction: column;
}
.item {
min-height: 3em;
margin-bottom: 0.5em;
border-radius: 2px;
user-select: none;
}
.item:last-child {
margin-bottom: 0;
}
.item:not(#grabbed):not(#ghost) {
z-index: 10;
}
.item > * {
margin: auto;
}
.buttons {
width: 32px;
min-width: 32px;
margin: auto 0;
display: flex;
flex-direction: column;
}
.buttons button {
cursor: pointer;
width: 18px;
height: 18px;
margin: 0 auto;
padding: 0;
border: 1px solid rgba(0, 0, 0, 0);
background-color: inherit;
}
.buttons button:focus {
border: 1px solid black;
}
.delete {
width: 32px;
}
#grabbed {
opacity: 0.0;
}
#ghost {
pointer-events: none;
z-index: -5;
position: absolute;
top: 0;
left: 0;
opacity: 0.0;
}
#ghost * {
pointer-events: none;
}
#ghost.haunting {
z-index: 20;
opacity: 1.0;
}
main {
position: relative;
}
</style>
<main>
<div class=dragdroplist>
<div
bind:this={ghost}
id="ghost"
class={grabbed ? "item haunting" : "item"}
style={"top: " + (mouseY + offsetY - layerY) + "px"}><p></p>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<h3 onclick={() => show_modal_edit_subheading_ingredient(list_index)}>
<div class="drag_handle drag_handle_header"><svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512"><path d="M0 96C0 78.3 14.3 64 32 64H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg></div>
<div>
{#if list.name }
{list.name}
{:else}
Leer
{/if}
</div>
<div class=mod_icons>
<button class="action_button button_subtle" onclick={() => show_modal_edit_subheading_ingredient(list_index)}>
<Pen fill=var(--nord1)></Pen> </button>
<button class="action_button button_subtle" onclick={() => remove_list(list_index)}>
<Cross fill=var(--nord1)></Cross></button>
</div>
</h3>
<div class="ingredients_grid list"
on:mousemove={function(ev) {ev.stopPropagation(); drag(ev.clientY);}}
on:touchmove={function(ev) {ev.stopPropagation(); drag(ev.touches[0].clientY);}}
on:mouseup={function(ev) {ev.stopPropagation(); release(ev);}}
on:touchend={function(ev) {ev.stopPropagation(); release(ev.touches[0]);}}
>
{#each list.list as ingredient, ingredient_index}
<span
id={(grabbed && (ingredient.id ? ingredient.id : JSON.stringify(ingredient)) == grabbed.dataset.id) ? "grabbed" : ""}
class="item"
data-index={ingredient_index}
data-id={(ingredient.id ? ingredient.id : JSON.stringify(ingredient))}
data-grabY="0"
on:mousedown={function(ev) {grab(ev.clientY, this);}}
on:touchstart={function(ev) {grab(ev.touches[0].clientY, this);}}
on:mouseenter={function(ev) {ev.stopPropagation(); dragEnter(ev, ev.target);}}
on:touchmove={function(ev) {ev.stopPropagation(); ev.preventDefault(); touchEnter(ev.touches[0]);}}
>
<div class=drag_handle><svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512"><path d="M0 96C0 78.3 14.3 64 32 64H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg></div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)} >{ingredient.amount} {ingredient.unit}</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)} >{@html ingredient.name}</div>
<div class=mod_icons><button class="action_button button_subtle" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)}>
<Pen fill=var(--nord1) height=1em width=1em></Pen></button>
<button class="action_button button_subtle" onclick="{() => remove_ingredient(list_index, ingredient_index)}"><Cross fill=var(--nord1) height=1em width=1em></Cross></button></div>
</span>
{/each}
</div>
</div>
</main>
<dialog id=edit_ingredient_modal>
<h2>Zutat verändern</h2>
<div class=adder>
<input class=category type="text" bind:value={edit_ingredient.sublist} placeholder="Kategorie (optional)">
<div class=add_ingredient onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<input type="text" placeholder="250..." bind:value={edit_ingredient.amount} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<input type="text" placeholder="mL..." bind:value={edit_ingredient.unit} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<input type="text" placeholder="Milch..." bind:value={edit_ingredient.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
<button class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)} onclick={edit_ingredient_and_close_modal}>
<Check fill=white style="width: 2rem; height: 2rem;"></Check>
</button>
</div>
</div>
</dialog>
<dialog id=edit_subheading_ingredient_modal>
<h2>Kategorie umbenennen</h2>
<div class=heading_wrapper>
<input class=heading type="text" bind:value={edit_heading.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} >
<button class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} onclick={edit_subheading_and_close_modal}>
<Check fill=white style="width:2rem; height:2rem;"></Check>
</button>
</div>
</dialog>
@@ -0,0 +1,452 @@
<script>
import { onMount } from 'svelte';
import { onNavigate } from "$app/navigation";
import { browser } from '$app/environment';
import { page } from '$app/stores';
import HefeSwapper from './HefeSwapper.svelte';
import '$lib/css/recipe-links.css';
let { data } = $props();
// Helper function to multiply numbers in ingredient amounts
function multiplyIngredientAmount(amount, multiplier) {
if (!amount || multiplier === 1) return amount;
return amount.replace(/(\d+(?:[\.,]\d+)?)/g, match => {
const number = match.includes(',') ? match.replace(/\./g, '').replace(',', '.') : match;
const multiplied = (parseFloat(number) * multiplier).toString();
const rounded = parseFloat(multiplied).toFixed(3);
const trimmed = parseFloat(rounded).toString();
return match.includes(',') ? trimmed.replace('.', ',') : trimmed;
});
}
// Recursively flatten nested ingredient references
function flattenIngredientReferences(items, lang, visited = new Set(), baseMultiplier = 1) {
const result = [];
for (const item of items) {
if (item.type === 'reference' && item.resolvedRecipe) {
// Prevent circular references
const recipeId = item.resolvedRecipe._id?.toString() || item.resolvedRecipe.short_name;
if (visited.has(recipeId)) {
console.warn('Circular reference detected:', recipeId);
continue;
}
const newVisited = new Set(visited);
newVisited.add(recipeId);
// Get translated or original ingredients
const ingredientsToUse = (lang === 'en' &&
item.resolvedRecipe.translations?.en?.ingredients)
? item.resolvedRecipe.translations.en.ingredients
: item.resolvedRecipe.ingredients || [];
// Calculate combined multiplier for this reference
const itemBaseMultiplier = item.baseMultiplier || 1;
const combinedMultiplier = baseMultiplier * itemBaseMultiplier;
// Recursively flatten nested references with the combined multiplier
const flattenedNested = flattenIngredientReferences(ingredientsToUse, lang, newVisited, combinedMultiplier);
// Combine all items into one list
const combinedList = [];
// Add items before (not affected by baseMultiplier)
if (item.itemsBefore && item.itemsBefore.length > 0) {
combinedList.push(...item.itemsBefore);
}
// Add base recipe ingredients (now recursively flattened with multiplier applied)
if (item.includeIngredients) {
flattenedNested.forEach(section => {
if (section.list) {
combinedList.push(...section.list);
}
});
}
// Add items after (not affected by baseMultiplier)
if (item.itemsAfter && item.itemsAfter.length > 0) {
combinedList.push(...item.itemsAfter);
}
// Push as one section with optional label
if (combinedList.length > 0) {
const baseRecipeName = (lang === 'en' && item.resolvedRecipe.translations?.en?.name)
? item.resolvedRecipe.translations.en.name
: item.resolvedRecipe.name;
const baseRecipeShortName = (lang === 'en' && item.resolvedRecipe.translations?.en?.short_name)
? item.resolvedRecipe.translations.en.short_name
: item.resolvedRecipe.short_name;
result.push({
type: 'section',
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
list: combinedList,
isReference: item.showLabel,
short_name: baseRecipeShortName,
baseMultiplier: itemBaseMultiplier
});
}
} else if (item.type === 'section' || !item.type) {
// Regular section - pass through with multiplier applied to amounts
if (baseMultiplier !== 1 && item.list) {
const adjustedList = item.list.map(ingredient => ({
...ingredient,
amount: multiplyIngredientAmount(ingredient.amount, baseMultiplier)
}));
result.push({
...item,
list: adjustedList
});
} else {
result.push(item);
}
}
}
return result;
}
// Flatten ingredient references for display
const flattenedIngredients = $derived.by(() => {
if (!data.ingredients) return [];
const lang = data.lang || 'de';
return flattenIngredientReferences(data.ingredients, lang);
});
let multiplier = $state(data.multiplier || 1);
const isEnglish = $derived(data.lang === 'en');
const labels = $derived({
portions: isEnglish ? 'Portions:' : 'Portionen:',
adjustAmount: isEnglish ? 'Adjust Amount:' : 'Menge anpassen:',
ingredients: isEnglish ? 'Ingredients' : 'Zutaten'
});
// Multiplier button options
const multiplierOptions = [
{ value: 0.5, label: '<sup>1</sup>/<sub>2</sub>x' },
{ value: 1, label: '1x' },
{ value: 1.5, label: '<sup>3</sup>/<sub>2</sub>x' },
{ value: 2, label: '2x' },
{ value: 3, label: '3x' }
];
// Calculate yeast IDs for each yeast ingredient
const yeastIds = $derived.by(() => {
const ids = {};
let yeastCounter = 0;
if (data.ingredients) {
for (let listIndex = 0; listIndex < data.ingredients.length; listIndex++) {
const list = data.ingredients[listIndex];
if (list.list) {
for (let ingredientIndex = 0; ingredientIndex < list.list.length; ingredientIndex++) {
const ingredient = list.list[ingredientIndex];
const nameLower = ingredient.name.toLowerCase();
if (nameLower === "frischhefe" || nameLower === "trockenhefe" ||
nameLower === "fresh yeast" || nameLower === "dry yeast") {
ids[`${listIndex}-${ingredientIndex}`] = yeastCounter++;
}
}
}
}
}
return ids;
});
// Get all current URL parameters to preserve state in multiplier forms
const currentParams = $derived(browser ? new URLSearchParams(window.location.search) : $page.url.searchParams);
// Progressive enhancement - use JS if available
onMount(() => {
if (browser) {
const urlParams = new URLSearchParams(window.location.search);
multiplier = parseFloat(urlParams.get('multiplier')) || 1;
}
})
onNavigate(() => {
if (browser) {
const urlParams = new URLSearchParams(window.location.search);
multiplier = parseFloat(urlParams.get('multiplier')) || 1;
}
})
function handleMultiplierClick(event, value) {
if (browser) {
event.preventDefault();
multiplier = value;
// Update URL without reloading
const url = new URL(window.location);
if (value === 1) {
url.searchParams.delete('multiplier');
} else {
url.searchParams.set('multiplier', value);
}
window.history.replaceState({}, '', url);
}
// If no JS, form will submit normally
}
function handleCustomInput(event) {
if (browser) {
const value = parseFloat(event.target.value);
if (!isNaN(value) && value > 0) {
multiplier = value;
// Update URL without reloading
const url = new URL(window.location);
if (value === 1) {
url.searchParams.delete('multiplier');
} else {
url.searchParams.set('multiplier', value);
}
window.history.replaceState({}, '', url);
}
}
}
function handleCustomSubmit(event) {
if (browser) {
event.preventDefault();
// Value already updated by handleCustomInput
}
// If no JS, form will submit normally
}
function convertFloatsToFractions(inputString) {
// Split the input string into individual words
const words = inputString.split(' ');
// Define a helper function to check if a number is close to an integer
const isCloseToInt = (num) => Math.abs(num - Math.round(num)) < 0.001;
// Function to convert a float to a fraction
const floatToFraction = (number) => {
let bestNumerator = 0;
let bestDenominator = 1;
let minDifference = Math.abs(number);
for (let denominator = 1; denominator <= 10; denominator++) {
const numerator = Math.round(number * denominator);
const difference = Math.abs(number - numerator / denominator);
if (difference < minDifference) {
bestNumerator = numerator;
bestDenominator = denominator;
minDifference = difference;
}
}
if (bestDenominator == 1) return bestNumerator;
else {
let full_amount = Math.floor(bestNumerator / bestDenominator);
if (full_amount > 0)
return `${full_amount}<sup>${bestNumerator - full_amount * bestDenominator}</sup>/<sub>${bestDenominator}</sub>`;
return `<sup>${bestNumerator}</sup>/<sub>${bestDenominator}</sub>`;
}
};
// Iterate through the words and convert floats to fractions
const result = words.map((word) => {
// Check if the word contains a range (e.g., "300-400")
if (word.includes('-')) {
const rangeNumbers = word.split('-');
const rangeFractions = rangeNumbers.map((num) => {
const number = parseFloat(num);
return !isNaN(number) ? floatToFraction(number) : num;
});
return rangeFractions.join('-');
} else {
const number = parseFloat(word);
return !isNaN(number) ? floatToFraction(number) : word;
}
});
// Join the words back into a string
return result.join(' ');
}
function multiplyNumbersInString(inputString, constant) {
return inputString.replace(/(\d+(?:[\.,]\d+)?)/g, match => {
const number = match.includes(',') ? match.replace(/\./g, '').replace(',', '.') : match;
const multiplied = (parseFloat(number) * constant).toString();
const rounded = parseFloat(multiplied).toFixed(3);
const trimmed = parseFloat(rounded).toString();
return match.includes(',') ? trimmed.replace('.', ',') : trimmed;
});
}
// "1-2 Kuchen (Durchmesser: 26cm", constant=2 -> "2-4 Kuchen (Durchmesser: 26cm)"
function multiplyFirstAndSecondNumbers(inputString, constant) {
const regex = /(\d+(?:[\.,]\d+)?)(\s*-\s*\d+(?:[\.,]\d+)?)?/;
return inputString.replace(regex, (match, firstNumber, secondNumber) => {
const numbersToMultiply = [firstNumber];
if (secondNumber) {
numbersToMultiply.push(secondNumber.replace(/-\s*/, ''));
}
const multipliedNumbers = numbersToMultiply.map(number => {
const multiplied = (parseFloat(number) * constant).toString();
const rounded = parseFloat(multiplied).toString();
const result = number.includes(',') ? rounded.replace('.', ',') : rounded;
return result;
});
return multipliedNumbers.join('-')
});
}
function adjust_amount(string, multiplier){
let temp = multiplyNumbersInString(string, multiplier)
temp = convertFloatsToFractions(temp)
return temp
}
// No need for complex yeast toggle handling - everything is calculated server-side now
</script>
<style>
*{
font-family: sans-serif;
}
.ingredients{
flex-basis: 0;
flex-grow: 1;
padding-block: 1rem;
padding-inline: 2rem;
}
.ingredients_grid{
display: grid;
font-size: 1.1rem;
grid-template-columns: 1fr 3fr;
grid-template-rows: auto;
grid-auto-flow: row;
row-gap: 0.5em;
column-gap: 0.5em;
}
.multipliers{
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
}
/* Size overrides for multiplier buttons */
.multipliers button{
min-width: 2em;
font-size: 1.1rem;
border-radius: var(--radius-sm);
}
/* Hover scale override - larger than default */
.multipliers :is(button, form):is(:hover, :focus-within){
scale: 1.2;
background-color: var(--nord8);
}
.selected{
background-color: var(--nord9) !important;
color: white !important;
font-weight: bold;
scale: 1.2 !important;
}
.custom-multiplier {
display: flex;
align-items: center;
min-width: 2em;
font-size: 1.1rem;
border-radius: var(--radius-sm);
}
.custom-input {
width: 3em;
padding: 0;
margin: 0;
border: none;
background: transparent;
text-align: center;
color: inherit;
font-size: inherit;
outline: none;
box-shadow: none;
}
/* Remove number input arrows */
.custom-input::-webkit-outer-spin-button,
.custom-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.custom-button {
padding: 0;
margin: 0;
border: none;
background: transparent;
color: inherit;
font-size: inherit;
cursor: pointer;
box-shadow: none;
}
</style>
{#if data.ingredients}
<div class=ingredients>
{#if data.portions}
<h3>{labels.portions}</h3>
{@html convertFloatsToFractions(multiplyFirstAndSecondNumbers(data.portions, multiplier))}
{/if}
<h3>{labels.adjustAmount}</h3>
<form method="get" class="multipliers">
{#each Array.from(currentParams.entries()) as [key, value]}
{#if key !== 'multiplier'}
<input type="hidden" name={key} {value} />
{/if}
{/each}
{#each multiplierOptions as opt}
<button type="submit" name="multiplier" value={opt.value} class="g-pill g-btn-light g-interactive" class:selected={multiplier === opt.value} onclick={(e) => handleMultiplierClick(e, opt.value)}>{@html opt.label}</button>
{/each}
<span class="custom-multiplier g-pill g-btn-light g-interactive">
<input
type="text"
name="multiplier"
pattern="[0-9]+(\.[0-9]*)?"
title="Enter a positive number (e.g., 2.5, 0.75, 3.14)"
placeholder="…"
class="custom-input"
value={!multiplierOptions.some(o => o.value === multiplier) ? multiplier : ''}
oninput={handleCustomInput}
/>
<button type="submit" class="custom-button">x</button>
</span>
</form>
<h2>{labels.ingredients}</h2>
{#each flattenedIngredients as list, listIndex}
{#if list.name}
{#if list.isReference}
<h3><a href="{list.short_name}?multiplier={multiplier * (list.baseMultiplier || 1)}">{@html list.name}</a></h3>
{:else}
<h3>{@html list.name}</h3>
{/if}
{/if}
{#if list.list}
<div class=ingredients_grid>
{#each list.list as item, ingredientIndex}
<div class=amount>{@html adjust_amount(item.amount, multiplier)} {item.unit}</div>
<div class=name>
{@html item.name.replace("{{multiplier}}", isNaN(parseFloat(item.amount)) ? multiplier : multiplier * parseFloat(item.amount))}
{#if item.name.toLowerCase() === "frischhefe" || item.name.toLowerCase() === "trockenhefe" || item.name.toLowerCase() === "fresh yeast" || item.name.toLowerCase() === "dry yeast"}
{@const yeastId = yeastIds[`${listIndex}-${ingredientIndex}`] ?? 0}
<HefeSwapper {item} {multiplier} {yeastId} lang={data.lang} />
{/if}
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
@@ -0,0 +1,205 @@
<script>
import '$lib/css/recipe-links.css';
let { data } = $props();
let multiplier = $state(data.multiplier || 1);
// Recursively flatten nested instruction references
function flattenInstructionReferences(items, lang, visited = new Set()) {
const result = [];
for (const item of items) {
if (item.type === 'reference' && item.resolvedRecipe) {
// Prevent circular references
const recipeId = item.resolvedRecipe._id?.toString() || item.resolvedRecipe.short_name;
if (visited.has(recipeId)) {
console.warn('Circular reference detected:', recipeId);
continue;
}
const newVisited = new Set(visited);
newVisited.add(recipeId);
// Get translated or original instructions
const instructionsToUse = (lang === 'en' &&
item.resolvedRecipe.translations?.en?.instructions)
? item.resolvedRecipe.translations.en.instructions
: item.resolvedRecipe.instructions || [];
// Recursively flatten nested references
const flattenedNested = flattenInstructionReferences(instructionsToUse, lang, newVisited);
// Combine all steps into one list
const combinedSteps = [];
// Add steps before
if (item.stepsBefore && item.stepsBefore.length > 0) {
combinedSteps.push(...item.stepsBefore);
}
// Add base recipe instructions (now recursively flattened)
if (item.includeInstructions) {
flattenedNested.forEach(section => {
if (section.steps) {
combinedSteps.push(...section.steps);
}
});
}
// Add steps after
if (item.stepsAfter && item.stepsAfter.length > 0) {
combinedSteps.push(...item.stepsAfter);
}
// Push as one section with optional label
if (combinedSteps.length > 0) {
const baseRecipeName = (lang === 'en' && item.resolvedRecipe.translations?.en?.name)
? item.resolvedRecipe.translations.en.name
: item.resolvedRecipe.name;
const baseRecipeShortName = (lang === 'en' && item.resolvedRecipe.translations?.en?.short_name)
? item.resolvedRecipe.translations.en.short_name
: item.resolvedRecipe.short_name;
const itemBaseMultiplier = item.baseMultiplier || 1;
result.push({
type: 'section',
name: item.showLabel ? (item.labelOverride || baseRecipeName) : '',
steps: combinedSteps,
isReference: item.showLabel,
short_name: baseRecipeShortName,
baseMultiplier: itemBaseMultiplier
});
}
} else if (item.type === 'section' || !item.type) {
// Regular section - pass through
result.push(item);
}
}
return result;
}
// Flatten instruction references for display
const flattenedInstructions = $derived.by(() => {
if (!data.instructions) return [];
const lang = data.lang || 'de';
return flattenInstructionReferences(data.instructions, lang);
});
const isEnglish = $derived(data.lang === 'en');
const labels = $derived({
preparation: isEnglish ? 'Preparation:' : 'Vorbereitung:',
bulkFermentation: isEnglish ? 'Bulk Fermentation:' : 'Stockgare:',
finalProof: isEnglish ? 'Final Proof:' : 'Stückgare:',
baking: isEnglish ? 'Baking:' : 'Backen:',
cooking: isEnglish ? 'Cooking:' : 'Kochen:',
onThePlate: isEnglish ? 'On the Plate:' : 'Auf dem Teller:',
instructions: isEnglish ? 'Instructions' : 'Zubereitung',
at: isEnglish ? 'at' : 'bei'
});
</script>
<style>
*{
font-family: sans-serif;
}
ol li::marker{
font-weight: bold;
color: var(--blue);
font-size: 1.2rem;
}
.instructions{
flex-basis: 0;
flex-grow: 2;
background-color: var(--nord5);
padding-block: 1rem;
padding-inline: 2rem;
}
.instructions ol{
padding-left: 1em;
}
.instructions li{
margin-block: 0.5em;
font-size: 1.1rem;
}
.additional_info{
display: flex;
flex-wrap: wrap;
gap: 1em;
}
.additional_info > *{
flex-grow: 0;
padding: 1em;
background-color: #FAFAFE;
box-shadow: var(--shadow-md);
max-width: 30%
}
@media (prefers-color-scheme: dark){
.instructions{
background-color: var(--nord6-dark);
}
.additional_info > *{
background-color: var(--accent-dark);
}
}
@media screen and (max-width: 500px){
.additional_info > *{
max-width: 60%;
}
}
h3{
margin-block: 0;
}
</style>
<div class=instructions>
<div class=additional_info>
{#if data.preparation}
<div><h3>{labels.preparation}</h3>{data.preparation}</div>
{/if}
{#if data.fermentation?.bulk}
<div><h3>{labels.bulkFermentation}</h3>{data.fermentation.bulk}</div>
{/if}
{#if data.fermentation?.final}
<div><h3>{labels.finalProof}</h3> {data.fermentation.final}</div>
{/if}
{#if data.baking?.temperature}
<div><h3>{labels.baking}</h3> {data.baking.length} {labels.at} {data.baking.temperature} °C {data.baking.mode}</div>
{/if}
{#if data.cooking}
<div><h3>{labels.cooking}</h3>{data.cooking}</div>
{/if}
{#if data.total_time}
<div><h3>{labels.onThePlate}</h3>{data.total_time}</div>
{/if}
</div>
{#if data.instructions}
<h2>{labels.instructions}</h2>
{#each flattenedInstructions as list}
{#if list.name}
{#if list.isReference}
<h3><a href="{list.short_name}?multiplier={multiplier * (list.baseMultiplier || 1)}">{@html list.name}</a></h3>
{:else}
<h3>{@html list.name}</h3>
{/if}
{/if}
{#if list.steps}
<ol>
{#each list.steps as step}
<li>{@html step}</li>
{/each}
</ol>
{/if}
{/each}
{/if}
</div>
@@ -0,0 +1,145 @@
<script>
import "$lib/css/nordtheme.css";
let {
useAndLogic = true,
onToggle = () => {},
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Filter Mode' : 'Filter-Modus');
const andLabel = $derived(isEnglish ? 'AND' : 'UND');
const orLabel = $derived(isEnglish ? 'OR' : 'ODER');
let checked = $state(useAndLogic);
// Watch for changes to checked and call onToggle
$effect(() => {
const currentChecked = checked;
onToggle(currentChecked);
});
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-width: 100%;
align-items: center;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
align-items: flex-start;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord2);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.filter-label {
color: var(--nord6);
}
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.toggle-container {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--nord4);
font-weight: 600;
}
@media (prefers-color-scheme: dark) {
.toggle-container {
color: var(--nord6);
}
}
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
background: var(--nord10);
border-radius: 24px;
cursor: pointer;
transition: background 0.3s ease;
}
.toggle-switch.or-mode {
background: var(--nord13);
}
.toggle-knob {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
top: 2px;
left: 2px;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle-switch.or-mode .toggle-knob {
transform: translateX(20px);
}
.mode-label {
min-width: 40px;
text-align: center;
}
.mode-label.active {
color: var(--nord10);
}
@media (prefers-color-scheme: dark) {
.mode-label.active {
color: var(--nord8);
}
}
.toggle-switch.or-mode + .mode-label.or {
color: var(--nord13);
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<div class="toggle-container">
<span class="mode-label" class:active={checked}>{andLabel}</span>
<div
class="toggle-switch"
class:or-mode={!checked}
onclick={() => checked = !checked}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); checked = !checked; } }}
>
<div class="toggle-knob"></div>
</div>
<span class="mode-label or" class:active={!checked}>{orLabel}</span>
</div>
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import "$lib/css/nordtheme.css"
let { title = '', children } = $props<{ title?: string, children?: Snippet }>();
</script>
<style>
.media-scroller {
display: flex;
flex-direction: row;
flex-wrap:nowrap;
overflow-x: auto;
/*gap: 2rem;*/ /*messes up if js disabled as anchor tag is inserted twice...*/
padding: 3rem;
}
.media_scroller_wrapper{
background-color: var(--nord2);
}
h2{
color: var(--nord6);
padding-top: 2rem;
margin: 1em 0em 0em 4rem;
font-size: 3rem;
}
</style>
<div class=media_scroller_wrapper>
{#if title}
<h2>{title}</h2>
{/if}
<div class="media-scroller snaps-inline">
{@render children?.()}
</div>
</div>
@@ -0,0 +1,79 @@
<script lang="ts">
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
import Card from '$lib/components/recipes/Card.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
let {
card_data = $bindable({}),
season = $bindable([]),
ingredients = $bindable([]),
instructions = $bindable([])
}: {
card_data?: any,
season?: any[],
ingredients?: any[],
instructions?: any[]
} = $props();
let short_name = $state();
let password = $state();
let datecreated = $state(new Date());
let datemodified = $state(datecreated);
async function doPost () {
const res = await fetch('/api/add', {
method: 'POST',
body: JSON.stringify({
recipe: {
season: season,
...card_data,
images: [{
mediapath: short_name + '.webp',
alt: "",
caption: ""
}],
short_name,
datecreated,
datemodified,
instructions,
ingredients,
},
headers: {
'content-type': 'application/json',
bearer: password,
}
})
})
const json = await res.json()
result = JSON.stringify(json)
}
</script>
<style>
input.temp{
all: unset;
display: block;
margin: 1rem auto;
padding: 0.2em 1em;
border-radius: 1000px;
background-color: var(--nord4);
}
</style>
<CardAdd bind:card_data={card_data}></CardAdd>
<input class=temp bind:value={short_name} placeholder="Kurzname"/>
<SeasonSelect bind:season={season}></SeasonSelect>
<button onclick={() => console.log(season)}>PRINTOUT season</button>
<h2>Zutaten</h2>
<CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList>
<h2>Zubereitung</h2>
<CreateStepList bind:instructions={instructions} ></CreateStepList>
<input class=temp type="password" placeholder=Passwort bind:value={password}>
@@ -0,0 +1,142 @@
<script lang="ts">
let {
germanUrl,
englishUrl,
currentLang = 'de',
hasTranslation = true
}: {
germanUrl: string,
englishUrl: string,
currentLang?: 'de' | 'en',
hasTranslation?: boolean
} = $props();
function setLanguagePreference(lang: 'de' | 'en') {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('preferredLanguage', lang);
}
}
</script>
<style>
.language-switcher {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
gap: 0.5rem;
background: var(--nord0);
padding: 0.5rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
@media(prefers-color-scheme: light) {
.language-switcher {
background: var(--nord6);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.language-switcher a {
padding: 0.5rem 1rem;
border-radius: 4px;
text-decoration: none;
color: var(--nord4);
font-weight: 600;
font-size: 0.9rem;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
@media(prefers-color-scheme: light) {
.language-switcher a {
color: var(--nord2);
}
}
.language-switcher a:hover {
background: var(--nord3);
color: var(--nord6);
}
@media(prefers-color-scheme: light) {
.language-switcher a:hover {
background: var(--nord4);
color: var(--nord0);
}
}
.language-switcher a.active {
background: var(--nord14);
color: var(--nord0);
}
.language-switcher a.active:hover {
background: var(--nord15);
}
.language-switcher a.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.flag {
font-size: 1.2rem;
line-height: 1;
}
@media (max-width: 600px) {
.language-switcher {
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem;
gap: 0.25rem;
}
.language-switcher a {
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
}
.flag {
font-size: 1rem;
}
}
</style>
<div class="language-switcher">
<a
href={germanUrl}
class:active={currentLang === 'de'}
aria-label="Switch to German"
onclick={() => setLanguagePreference('de')}
>
<span class="flag">🇩🇪</span>
<span class="label">DE</span>
</a>
{#if hasTranslation}
<a
href={englishUrl}
class:active={currentLang === 'en'}
aria-label="Switch to English"
onclick={() => setLanguagePreference('en')}
>
<span class="flag">🇬🇧</span>
<span class="label">EN</span>
</a>
{:else}
<span
class="disabled"
title="English translation not available"
aria-label="English translation not available"
>
<span class="flag">🇬🇧</span>
<span class="label">EN</span>
</span>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
let { note, ...restProps } = $props<{ note: string, [key: string]: any }>();
</script>
<style>
div{
background-color: var(--red);
color: white;
padding: 1em;
font-size: 1.1rem;
max-width: 400px;
margin-inline: auto;
box-shadow: 0.2em 0.2em 0.5em 0.1em rgba(0, 0, 0, 0.3);
margin-bottom: 1em;
}
h3{
margin-block: 0;
}
</style>
<div {...restProps} >
<h3>Notiz:</h3>
{@html note}
</div>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { title = '', children } = $props<{ title?: string, children?: Snippet }>();
let overflow = $state();
</script>
<style>
.wrapper{
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2.5rem;
justify-content: center;
}
h2{
max-width: 1000px;
margin-left: 5rem;
margin-bottom: 0;
font-size: 3rem;
margin-bottom: 1rem;
}
section:not(:has(h2)){
padding-top: 4rem;
}
section{
overflow: hidden;
padding-bottom: 3.7rem;
}
</style>
<section>
{#if title}
<h2>{title}</h2>
{/if}
<div class=wrapper>
{@render children?.()}
</div>
</section>
+411
View File
@@ -0,0 +1,411 @@
<script>
import {onMount} from "svelte";
import { browser } from '$app/environment';
import "$lib/css/nordtheme.css";
import FilterPanel from './FilterPanel.svelte';
import { getCategories } from '$lib/js/categories';
// Filter props for different contexts
let {
category = null,
tag = null,
icon = null,
season = null,
favoritesOnly = false,
lang = 'de',
recipes = [],
onSearchResults = (matchedIds, matchedCategories) => {},
isLoggedIn = false
} = $props();
// Generate categories internally based on language
const categories = $derived(getCategories(lang));
const isEnglish = $derived(lang === 'en');
const searchResultsUrl = $derived(isEnglish ? '/recipes/search' : '/rezepte/search');
const labels = $derived({
placeholder: isEnglish ? 'Search...' : 'Suche...',
searchTitle: isEnglish ? 'Search' : 'Suchen',
clearTitle: isEnglish ? 'Clear search' : 'Sucheintrag löschen'
});
let searchQuery = $state('');
let showFilters = $state(false);
// Filter data loaded from APIs
let availableTags = $state([]);
let availableIcons = $state([]);
// Selected filters (internal state)
let selectedCategory = $state(null);
let selectedTags = $state([]);
let selectedIcon = $state(null);
let selectedSeasons = $state([]);
let selectedFavoritesOnly = $state(false);
let useAndLogic = $state(true);
// Initialize from props (for backwards compatibility)
$effect(() => {
selectedCategory = category || null;
selectedTags = tag ? [tag] : [];
selectedIcon = icon || null;
selectedSeasons = season ? [parseInt(season)] : [];
selectedFavoritesOnly = favoritesOnly;
});
// Apply non-text filters (category, tags, icon, season)
function applyNonTextFilters(recipeList) {
return recipeList.filter(recipe => {
if (useAndLogic) {
// AND mode: recipe must satisfy ALL active filters
// Category filter (single value in AND mode)
if (selectedCategory && recipe.category !== selectedCategory) {
return false;
}
// Multi-tag AND logic: recipe must have ALL selected tags
if (selectedTags.length > 0) {
const recipeTags = recipe.tags || [];
if (!selectedTags.every(tag => recipeTags.includes(tag))) {
return false;
}
}
// Icon filter (single value in AND mode)
if (selectedIcon && recipe.icon !== selectedIcon) {
return false;
}
// Season filter: recipe in any selected season
if (selectedSeasons.length > 0) {
const recipeSeasons = recipe.season || [];
if (!selectedSeasons.some(s => recipeSeasons.includes(s))) {
return false;
}
}
// Favorites filter
if (selectedFavoritesOnly && !recipe.isFavorite) {
return false;
}
return true;
} else {
// OR mode: recipe must satisfy AT LEAST ONE active filter
const categoryArray = Array.isArray(selectedCategory) ? selectedCategory : (selectedCategory ? [selectedCategory] : []);
const iconArray = Array.isArray(selectedIcon) ? selectedIcon : (selectedIcon ? [selectedIcon] : []);
const hasActiveFilters = categoryArray.length > 0 || selectedTags.length > 0 || iconArray.length > 0 || selectedSeasons.length > 0 || selectedFavoritesOnly;
// If no filters active, return all
if (!hasActiveFilters) {
return true;
}
// Check if recipe matches any filter
const matchesCategory = categoryArray.length > 0 ? categoryArray.includes(recipe.category) : false;
const matchesTags = selectedTags.length > 0 ? selectedTags.some(tag => (recipe.tags || []).includes(tag)) : false;
const matchesIcon = iconArray.length > 0 ? iconArray.includes(recipe.icon) : false;
const matchesSeasons = selectedSeasons.length > 0 ? selectedSeasons.some(s => (recipe.season || []).includes(s)) : false;
const matchesFavorites = selectedFavoritesOnly ? recipe.isFavorite : false;
return matchesCategory || matchesTags || matchesIcon || matchesSeasons || matchesFavorites;
}
});
}
// Perform search directly (no worker)
function performSearch(query) {
// Apply non-text filters first
const filteredByNonText = applyNonTextFilters(recipes);
// Empty query = show all (filtered) recipes
if (!query || query.trim().length === 0) {
onSearchResults(
new Set(filteredByNonText.map(r => r._id)),
new Set(filteredByNonText.map(r => r.category))
);
return;
}
// Normalize and split search query
const searchText = query.toLowerCase().trim()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ").filter(term => term.length > 0);
// Filter recipes by text
const matched = filteredByNonText.filter(recipe => {
// Build searchable string from recipe data
const searchString = [
recipe.name || '',
recipe.description || '',
...(recipe.tags || [])
].join(' ')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "")
.replace(/&shy;|­/g, ''); // Remove soft hyphens
// All search terms must match
return searchTerms.every(term => searchString.includes(term));
});
// Return matched recipe IDs and categories
onSearchResults(
new Set(matched.map(r => r._id)),
new Set(matched.map(r => r.category))
);
}
// Build search URL with current filters
function buildSearchUrl(query) {
if (browser) {
const url = new URL(searchResultsUrl, window.location.origin);
if (query) url.searchParams.set('q', query);
if (selectedCategory) url.searchParams.set('category', selectedCategory);
// Multiple tags: use comma-separated format
if (selectedTags.length > 0) {
url.searchParams.set('tags', selectedTags.join(','));
}
if (selectedIcon) url.searchParams.set('icon', selectedIcon);
// Multiple seasons: use comma-separated format
if (selectedSeasons.length > 0) {
url.searchParams.set('seasons', selectedSeasons.join(','));
}
if (selectedFavoritesOnly) url.searchParams.set('favorites', 'true');
return url.toString();
} else {
// Server-side fallback - return just the base path
return searchResultsUrl;
}
}
// Filter change handlers - the effect will automatically trigger search
function handleCategoryChange(newCategory) {
selectedCategory = newCategory;
}
function handleTagToggle(tag) {
if (selectedTags.includes(tag)) {
selectedTags = selectedTags.filter(t => t !== tag);
} else {
selectedTags = [...selectedTags, tag];
}
}
function handleIconChange(newIcon) {
selectedIcon = newIcon;
}
function handleSeasonChange(newSeasons) {
selectedSeasons = newSeasons;
}
function handleFavoritesToggle(enabled) {
selectedFavoritesOnly = enabled;
}
function handleLogicModeToggle(useAnd) {
useAndLogic = useAnd;
// When switching to AND mode, convert arrays to single values
if (useAnd) {
if (Array.isArray(selectedCategory)) {
selectedCategory = selectedCategory.length > 0 ? selectedCategory[0] : null;
}
if (Array.isArray(selectedIcon)) {
selectedIcon = selectedIcon.length > 0 ? selectedIcon[0] : null;
}
}
}
function handleSubmit(event) {
if (browser) {
// For JS-enabled browsers, prevent default and navigate programmatically
const url = buildSearchUrl(searchQuery);
window.location.href = url;
}
// If no JS, form will submit normally
}
function clearSearch() {
searchQuery = '';
performSearch('');
}
// Debounced search effect - triggers search when query or filters change
$effect(() => {
if (browser && recipes.length > 0) {
// Read all dependencies to track them
const query = searchQuery;
const cat = selectedCategory;
const tags = selectedTags;
const icn = selectedIcon;
const seasons = selectedSeasons;
const favsOnly = selectedFavoritesOnly;
const andLogic = useAndLogic;
// Set debounce timer
const timer = setTimeout(() => {
performSearch(query);
}, 100);
// Cleanup function - clear timer on re-run or unmount
return () => clearTimeout(timer);
}
});
// Load filter data reactively when language changes
$effect(() => {
const loadFilterData = async () => {
try {
const apiBase = isEnglish ? '/api/recipes' : '/api/rezepte';
const [tagsRes, iconsRes] = await Promise.all([
fetch(`${apiBase}/items/tag`),
fetch('/api/rezepte/items/icon')
]);
availableTags = await tagsRes.json();
availableIcons = await iconsRes.json();
} catch (error) {
console.error('Failed to load filter data:', error);
}
};
loadFilterData();
});
onMount(async () => {
// Swap buttons for JS-enabled experience
const submitButton = document.getElementById('submit-search');
const clearButton = document.getElementById('clear-search');
if (submitButton && clearButton) {
submitButton.style.display = 'none';
clearButton.style.display = 'flex';
}
// Enable filter panel for JS-enabled browsers
showFilters = true;
// Get initial search value from URL if present
const urlParams = new URLSearchParams(window.location.search);
const urlQuery = urlParams.get('q');
if (urlQuery) {
searchQuery = urlQuery;
} else {
// Show all recipes initially
performSearch('');
}
});
</script>
<style>
input#search {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: #fff;
padding: 0.7rem 2rem;
border-radius: 1000px;
width: 100%;
}
input::placeholder{
color: var(--nord6);
}
.search {
width: 500px;
max-width: 85vw;
position: relative;
margin: 2.5rem auto 1.2rem;
font-size: 1.6rem;
display: flex;
align-items: center;
transition: 100ms;
filter: drop-shadow(0.4em 0.5em 0.4em rgba(0,0,0,0.4))
}
.search:hover,
.search:focus-within
{
scale: 1.02 1.02;
filter: drop-shadow(0.4em 0.5em 1em rgba(0,0,0,0.6))
}
.search-button {
all: unset;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
right: 0.5em;
width: 1.5em;
height: 1.5em;
color: var(--nord6);
cursor: pointer;
transition: color 180ms ease-in-out;
}
.search-button:hover {
color: white;
scale: 1.1 1.1;
}
.search-button:active{
transition: 50ms;
scale: 0.8 0.8;
}
.search-button svg {
width: 100%;
height: 100%;
}
</style>
<form class="search" method="get" action={buildSearchUrl('')} onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
{#if selectedCategory}<input type="hidden" name="category" value={selectedCategory} />{/if}
{#each selectedTags as tag}
<input type="hidden" name="tag" value={tag} />
{/each}
{#if selectedIcon}<input type="hidden" name="icon" value={selectedIcon} />{/if}
{#each selectedSeasons as season}
<input type="hidden" name="season" value={season} />
{/each}
{#if selectedFavoritesOnly}<input type="hidden" name="favorites" value="true" />{/if}
<input type="text" id="search" name="q" placeholder={labels.placeholder} bind:value={searchQuery}>
<!-- Submit button (visible by default, hidden when JS loads) -->
<button type="submit" id="submit-search" class="search-button" style="display: flex;">
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512" style="width: 100%; height: 100%;"><title>{labels.searchTitle}</title><path d="M221.09 64a157.09 157.09 0 10157.09 157.09A157.1 157.1 0 00221.09 64z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="m338.29 338.29 105.25 105.25"></path></svg>
</button>
<!-- Clear button (hidden by default, shown when JS loads) -->
<button type="button" id="clear-search" class="search-button js-only" style="display: none;" onclick={clearSearch}>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><title>{labels.clearTitle}</title><path d="M135.19 390.14a28.79 28.79 0 0021.68 9.86h246.26A29 29 0 00432 371.13V140.87A29 29 0 00403.13 112H156.87a28.84 28.84 0 00-21.67 9.84v0L46.33 256l88.86 134.11z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"></path><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33M336.67 192.33L206.66 322.34M336.67 322.34L206.66 192.33"></path></svg>
</button>
</form>
{#if showFilters}
<FilterPanel
availableCategories={categories}
{availableTags}
{availableIcons}
{selectedCategory}
{selectedTags}
{selectedIcon}
{selectedSeasons}
{selectedFavoritesOnly}
{useAndLogic}
{lang}
{isLoggedIn}
hideFavoritesFilter={favoritesOnly}
onCategoryChange={handleCategoryChange}
onTagToggle={handleTagToggle}
onIconChange={handleIconChange}
onSeasonChange={handleSeasonChange}
onFavoritesToggle={handleFavoritesToggle}
onLogicModeToggle={handleLogicModeToggle}
/>
{/if}
@@ -0,0 +1,225 @@
<script>
import "$lib/css/nordtheme.css";
import TagChip from '$lib/components/recipes/TagChip.svelte';
let {
selectedSeasons = [],
onChange = () => {},
lang = 'de',
months = []
} = $props();
const isEnglish = $derived(lang === 'en');
const label = $derived(isEnglish ? 'Season' : 'Saison');
const selectLabel = $derived(isEnglish ? 'Select season...' : 'Saison auswählen...');
let inputValue = $state('');
let dropdownOpen = $state(false);
// Available month options (not yet selected)
const availableMonths = $derived(
months.map((month, i) => ({ name: month, number: i + 1 }))
.filter(m => !selectedSeasons.includes(m.number))
);
// Filter months based on input
const filteredMonths = $derived(
inputValue.trim() === ''
? availableMonths
: availableMonths.filter(m =>
m.name.toLowerCase().includes(inputValue.toLowerCase())
)
);
// Selected months for display
const selectedMonthNames = $derived(
selectedSeasons
.map(num => ({ name: months[num - 1], number: num }))
.filter(m => m.name) // Filter out invalid months
);
function handleInputFocus() {
dropdownOpen = true;
}
function handleInputBlur() {
setTimeout(() => {
dropdownOpen = false;
inputValue = '';
}, 200);
}
function handleMonthSelect(monthNumber) {
onChange([...selectedSeasons, monthNumber]);
inputValue = '';
}
function handleMonthRemove(monthNumber) {
onChange(selectedSeasons.filter(m => m !== monthNumber));
}
function handleKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault();
const value = inputValue.trim();
const matchedMonth = availableMonths.find(m =>
m.name.toLowerCase() === value.toLowerCase()
) || filteredMonths[0];
if (matchedMonth) {
handleMonthSelect(matchedMonth.number);
}
} else if (event.key === 'Escape') {
dropdownOpen = false;
inputValue = '';
}
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
max-width: 100%;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord2);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.filter-label {
color: var(--nord6);
}
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.input-wrapper {
position: relative;
}
input {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
border-radius: 6px;
width: 100%;
transition: all 100ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.9rem;
}
@media (max-width: 968px) {
input {
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
}
input::placeholder {
color: var(--nord4);
}
input:hover {
background: var(--nord2);
}
input:focus-visible {
outline: 2px solid var(--nord10);
outline-offset: 2px;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.3rem;
background: var(--nord0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.3));
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.selected-seasons {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<!-- Input with custom dropdown -->
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeyDown}
placeholder={selectLabel}
autocomplete="off"
/>
<!-- Custom dropdown with month chips -->
{#if dropdownOpen && filteredMonths.length > 0}
<div class="dropdown">
{#each filteredMonths as month}
<TagChip
tag={month.name}
selected={false}
removable={false}
onToggle={() => handleMonthSelect(month.number)}
/>
{/each}
</div>
{/if}
</div>
<!-- Selected seasons display below -->
{#if selectedMonthNames.length > 0}
<div class="selected-seasons">
{#each selectedMonthNames as month}
<TagChip
tag={month.name}
selected={true}
removable={true}
onToggle={() => handleMonthRemove(month.number)}
/>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import '$lib/css/nordtheme.css';
import Recipes from '$lib/components/recipes/Recipes.svelte';
import Search from './Search.svelte';
let {
months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
active_index,
routePrefix = '/rezepte',
lang = 'de',
recipes = [],
isLoggedIn = false,
onSearchResults = (ids, categories) => {},
recipesSlot
}: {
months?: string[],
active_index: number,
routePrefix?: string,
lang?: string,
recipes?: any[],
isLoggedIn?: boolean,
onSearchResults?: (ids: any[], categories: any[]) => void,
recipesSlot?: Snippet
} = $props();
let month: number = $state();
</script>
<style>
a.month{
text-decoration: unset;
font-family: sans-serif;
border-radius: 1000px;
background-color: var(--blue);
color: var(--nord5);
padding: 0.5em;
transition: 100ms;
min-width: 4em;
text-align: center;
}
a.month:hover,
.active
{
transform: scale(1.1,1.1) !important;
background-color: var(--red) !important;
}
.months{
display:flex;
flex-wrap:wrap;
justify-content: center;
gap: 1rem;
margin-inline: auto;
margin-block: 2rem;
}
</style>
<div class=months>
{#each months as month, i}
<a class:active={i == active_index} class=month href="{routePrefix}/season/{i+1}">{month}</a>
{/each}
</div>
<section>
<Search season={active_index + 1} {lang} {recipes} {isLoggedIn} {onSearchResults}></Search>
</section>
<section>
{@render recipesSlot?.()}
</section>
@@ -0,0 +1,99 @@
<script lang=ts>
import "$lib/css/nordtheme.css"
import { season } from '$lib/js/season_store.js'
import {onMount} from "svelte";
import {do_on_key} from "./do_on_key";
let months = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
let season_local
season.subscribe((s) => {
season_local = s
});
export function set_season(){
let temp = []
const el = document.getElementById("labels");
for(var i = 0; i < el.children.length; i++){
if(el.children[i].children[0].children[0].checked){
temp.push(i+1)
}
}
season.update((s) => temp)
}
function write_season(season){
const el = document.getElementById("labels");
for(var i = 0; i < season.length; i++){
el.children[season[i]-1].children[0].children[0].checked = true
}
}
function toggle_checkbox_on_key(event){
event.path[0].children[0].checked = !event.path[0].children[0].checked
}
onMount(() => {
write_season(season_local)
});
</script>
<style>
label{
background-color: var(--nord0);
color: white;
padding: 0.25em 1em;
margin-inline: 0.1em;
line-height: 2em;
border-radius: 1000px;
cursor: pointer;
position: relative;
transition: 100ms;
user-select: none;
}
.checkbox_container{
transition: 100ms;
}
.checkbox_container:hover,
.checkbox_container:focus-within
{
transform: scale(1.1,1.1);
}
label:hover,
label:focus-visible
{
background-color: var(--lightblue);
}
label:has(input:checked){
background-color: var(--blue);
}
input[type=checkbox],
input[type=checkbox]::before,
input[type=checkbox]::after
{
all: unset;
user-select: none;
}
#labels{
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: center;
margin-bottom: 1em;
}
</style>
<div id=labels>
{#each months as month}
<div class=checkbox_container>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<label tabindex="0" onkeydown={(event) => do_on_key(event, 'Enter', false, () => {toggle_checkbox_on_key(event)}) } ><input tabindex=-1 type="checkbox" name="checkbox" value="value" onclick={set_season}>{month}</label>
</div>
{/each}
</div>
+74
View File
@@ -0,0 +1,74 @@
<script>
import "$lib/css/nordtheme.css";
let {
tag = '',
selected = false,
onToggle = () => {},
removable = true
} = $props();
function handleClick() {
onToggle(tag);
}
</script>
<style>
.tag-chip {
all: unset;
padding: 0.4rem 0.8rem;
border-radius: 1000px;
font-size: 0.9rem;
cursor: pointer;
transition: all 100ms ease;
display: inline-flex;
align-items: center;
gap: 0.4rem;
user-select: none;
white-space: nowrap;
}
.tag-chip.available {
background: var(--nord2);
color: var(--nord6);
}
.tag-chip.available:hover {
background: var(--nord3);
transform: scale(1.05);
}
.tag-chip.selected {
background: var(--nord10);
color: white;
}
.tag-chip.selected:hover {
background: var(--nord9);
transform: scale(1.05);
}
.tag-chip:active {
transform: scale(0.95);
}
.remove-icon {
font-size: 0.8rem;
font-weight: bold;
margin-left: 0.2rem;
}
</style>
<button
class="tag-chip"
class:available={!selected}
class:selected={selected}
onclick={handleClick}
type="button"
aria-pressed={selected}
>
{tag}
{#if selected && removable}
<span class="remove-icon" aria-hidden="true">×</span>
{/if}
</button>
+223
View File
@@ -0,0 +1,223 @@
<script>
import "$lib/css/nordtheme.css";
import TagChip from '$lib/components/recipes/TagChip.svelte';
let {
availableTags = [],
selectedTags = [],
onToggle = () => {},
lang = 'de'
} = $props();
const isEnglish = $derived(lang === 'en');
const label = 'Tags';
const addTagLabel = $derived(isEnglish ? 'Type or select tag...' : 'Tag eingeben oder auswählen...');
// Filter out already selected tags
const unselectedTags = $derived(availableTags.filter(t => !selectedTags.includes(t)));
let inputValue = $state('');
let dropdownOpen = $state(false);
let dropdownElement = $state(null);
// Filter tags based on input
const filteredTags = $derived(
inputValue.trim() === ''
? unselectedTags
: unselectedTags.filter(tag =>
tag.toLowerCase().includes(inputValue.toLowerCase())
)
);
function handleInputFocus() {
dropdownOpen = true;
}
function handleInputBlur(event) {
// Delay to allow click events on dropdown items
setTimeout(() => {
dropdownOpen = false;
inputValue = '';
}, 200);
}
function handleTagSelect(tag) {
onToggle(tag);
inputValue = '';
dropdownOpen = false;
}
function handleKeyDown(event) {
if (event.key === 'Enter') {
event.preventDefault();
const value = inputValue.trim();
// Try to find exact match or first filtered result
const matchedTag = availableTags.find(t => t.toLowerCase() === value.toLowerCase())
|| filteredTags[0];
if (matchedTag && !selectedTags.includes(matchedTag)) {
onToggle(matchedTag);
inputValue = '';
}
} else if (event.key === 'Escape') {
dropdownOpen = false;
inputValue = '';
}
}
</script>
<style>
.filter-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
position: relative;
max-width: 100%;
}
@media (max-width: 968px) {
.filter-section {
max-width: 500px;
gap: 0.3rem;
margin: 0 auto;
width: 100%;
}
}
.filter-label {
font-size: 0.9rem;
color: var(--nord2);
font-weight: 600;
margin-bottom: 0.25rem;
text-align: center;
}
@media (prefers-color-scheme: dark) {
.filter-label {
color: var(--nord6);
}
}
@media (max-width: 968px) {
.filter-label {
font-size: 0.85rem;
text-align: left;
}
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.3rem;
}
.input-wrapper {
position: relative;
}
input {
all: unset;
box-sizing: border-box;
font-family: sans-serif;
background: var(--nord0);
color: var(--nord6);
padding: 0.5rem 0.7rem;
border-radius: 6px;
width: 100%;
transition: all 100ms ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-size: 0.9rem;
}
@media (max-width: 968px) {
input {
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
}
input::placeholder {
color: var(--nord4);
}
input:hover {
background: var(--nord2);
}
input:focus-visible {
outline: 2px solid var(--nord10);
outline-offset: 2px;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.3rem;
background: var(--nord0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.3));
max-height: 200px;
overflow-y: auto;
z-index: 100;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.dropdown:empty::after {
content: 'No tags found';
color: var(--nord3);
font-size: 0.85rem;
font-style: italic;
padding: 0.5rem;
}
</style>
<div class="filter-section">
<div class="filter-label">{label}</div>
<!-- Input with custom dropdown -->
<div class="input-wrapper">
<input
type="text"
bind:value={inputValue}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleKeyDown}
placeholder={addTagLabel}
autocomplete="off"
/>
<!-- Custom dropdown with tag chips -->
{#if dropdownOpen && filteredTags.length > 0}
<div class="dropdown" bind:this={dropdownElement}>
{#each filteredTags as tag}
<TagChip
{tag}
selected={false}
removable={false}
onToggle={() => handleTagSelect(tag)}
/>
{/each}
</div>
{/if}
</div>
<!-- Selected tags display below -->
{#if selectedTags.length > 0}
<div class="selected-tags">
{#each selectedTags as tag}
<TagChip
{tag}
selected={true}
removable={true}
onToggle={onToggle}
/>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,198 @@
<script>
import { onMount } from "svelte";
let { src, placeholder_src, alt = "", children } = $props();
let isloaded = $state(false);
let isredirected = $state(false);
onMount(() => {
const el = document.querySelector("img")
if(el?.complete){
isloaded = true
}
fetch(src, { method: 'HEAD' })
.then(response => {
isredirected = response.redirected
})
})
function show_dialog_img(){
if(isredirected){
return
}
if(document.querySelector("img").complete){
document.querySelector("#img_carousel").showModal();
}
}
function close_dialog_img(){
document.querySelector("#img_carousel").close();
}
import Cross from "$lib/assets/icons/Cross.svelte";
import "$lib/css/action_button.css";
import "$lib/css/shake.css";
import { do_on_key } from "$lib/components/recipes/do_on_key";
</script>
<style>
:root {
--scale: 0.3;
--space: 10vw;
--font-primary: 'Lato', sans-serif;
--font-heading: 'Playfair Display', serif;
}
@media (prefers-reduced-motion) {
:root {
--scale: 0;
}
}
* {
box-sizing: border-box;
}
.section {
margin-bottom: -20vh;
transform-origin: center top;
transform: translateY(-1rem)
scaleY(calc(1 - var(--scale)));
}
.section > * {
transform-origin: center top;
transform: scaleY(calc(1 / (1 - var(--scale))));
}
.content {
position: relative;
margin: 30vh auto 0;
}
.image-container {
position: sticky;
display: flex;
align-items: center;
justify-content: center;
top: 0;
height: max(50dvh, 500px);
z-index: -10;
margin: 0;
}
.image{
display: block;
position: absolute;
top: 0;
width: min(1000px, 100dvw);
z-index: -1;
opacity: 0;
transition: 200ms;
height: max(60dvh,600px);
object-fit: cover;
object-position: 50% 20%;
backdrop-filter: blur(20px);
filter: blur(20px);
z-index: -10;
}
.image-container::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
height: 50%;
}
:global(h1){
width: 100%;
}
.placeholder{
background-repeat: no-repeat;
background-size: cover;
background-position: 50% 20%;
position: absolute;
width: min(1000px, 100dvw);
height: max(60dvh,600px);
z-index: -2;
}
.placeholder_blur{
width: inherit;
height: inherit;
backdrop-filter: blur(20px);
}
div:has(.placeholder){
position: absolute;
top: 0;
left: 0;
right: 0;
margin-inline: auto;
width: min(1000px, 100dvw);
height: max(60dvh,600px);
overflow: hidden;
}
.unblur.image{
filter: blur(0px) !important;
opacity: 1;
}
/* DIALOG */
dialog{
position: relative;
background-color: unset;
padding:0;
max-height: 90vh;
margin-inline: auto;
overflow: visible;
border: unset;
}
dialog img{
max-width: calc(95vmin - 2rem);
max-height: 95vmin; /* cannot use calc() for some reason */
}
dialog[open]::backdrop{
animation: show 200ms ease forwards;
}
@keyframes show{
from {
backdrop-filter: blur(0px);
}
to {
backdrop-filter: blur(10px);
}
}
dialog button{
position: absolute;
top: -2rem;
right: -2rem;
}
.zoom-in{
cursor: zoom-in;
}
</style>
<section class="section">
<figure class="image-container">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class:zoom-in={isloaded && !isredirected} onclick={show_dialog_img}>
<div class=placeholder style="background-image:url({placeholder_src})" >
<div class=placeholder_blur>
<img class="image" class:unblur={isloaded} {src} onload={() => {isloaded=true}} {alt}/>
</div>
</div>
<noscript>
<div class=placeholder style="background-image:url({placeholder_src})" >
<img class="image unblur" {src} onload={() => {isloaded=true}} {alt}/>
</div>
</noscript>
</div>
</figure>
<div class=content>{@render children()}</div>
</section>
<dialog id=img_carousel>
<img class:unblur={isloaded} {src} {alt}>
<button class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, close_dialog_img)} onclick={close_dialog_img}>
<Cross fill=white width=2rem height=2rem></Cross>
</button>
</dialog>
@@ -0,0 +1,922 @@
<script lang="ts">
import type { TranslatedRecipeType } from '$types/types';
import TranslationFieldComparison from './TranslationFieldComparison.svelte';
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
import GenerateAltTextButton from './GenerateAltTextButton.svelte';
interface Props {
germanData: any;
englishData?: TranslatedRecipeType | null;
changedFields?: string[];
isEditMode?: boolean;
oldRecipeData?: any;
onapproved?: (event: CustomEvent) => void;
onskipped?: () => void;
oncancelled?: () => void;
onforceFullRetranslation?: () => void;
}
let {
germanData,
englishData = null,
changedFields = [],
isEditMode = false,
oldRecipeData = null,
onapproved,
onskipped,
oncancelled,
onforceFullRetranslation
}: Props = $props();
type TranslationState = 'idle' | 'translating' | 'preview' | 'approved' | 'error';
let translationState = $state<TranslationState>(englishData ? 'preview' : 'idle');
let errorMessage = $state('');
let validationErrors = $state<string[]>([]);
// Helper function to initialize images array for English translation
function initializeImagesArray(germanImages: any[]): any[] {
if (!germanImages || germanImages.length === 0) return [];
return germanImages.map((img) => ({
mediapath: img.mediapath || '',
alt: '',
caption: ''
}));
}
// Eagerly initialize editableEnglish from germanData if no English translation exists
let editableEnglish = $state<any>(
englishData ? {
...englishData,
images: englishData.images || initializeImagesArray(germanData.images || [])
} : {
...germanData,
translationStatus: 'pending',
ingredients: JSON.parse(JSON.stringify(germanData.ingredients || [])),
instructions: JSON.parse(JSON.stringify(germanData.instructions || [])),
images: initializeImagesArray(germanData.images || [])
}
);
// Translation metadata (tracks which items were re-translated)
let translationMetadata = $state<any>(null);
// Track base recipes that need translation
let untranslatedBaseRecipes = $state<{ shortName: string, name: string }[]>([]);
let checkingBaseRecipes = $state(false);
// Ensure images array is properly synced when germanData changes
$effect(() => {
if (germanData?.images && (!editableEnglish.images || editableEnglish.images.length !== germanData.images.length)) {
// Re-initialize images array to match germanData length
editableEnglish.images = initializeImagesArray(germanData.images);
}
});
// Sync base recipe references from German to English
async function syncBaseRecipeReferences() {
if (!germanData) return;
checkingBaseRecipes = true;
// Helper to extract short_name from baseRecipeRef (which might be an object or string)
const getShortName = (baseRecipeRef: any): string => {
return typeof baseRecipeRef === 'object' ? baseRecipeRef.short_name : baseRecipeRef;
};
// Collect all base recipe references from German data
const germanBaseRecipeShortNames = new Set<string>();
const baseRecipeRefMap = new Map<string, any>(); // Map short_name to baseRecipeRef (ID or object)
(germanData.ingredients || []).forEach((ing: any) => {
if (ing.type === 'reference' && ing.baseRecipeRef) {
const shortName = getShortName(ing.baseRecipeRef);
germanBaseRecipeShortNames.add(shortName);
baseRecipeRefMap.set(shortName, ing.baseRecipeRef);
}
});
(germanData.instructions || []).forEach((inst: any) => {
if (inst.type === 'reference' && inst.baseRecipeRef) {
const shortName = getShortName(inst.baseRecipeRef);
germanBaseRecipeShortNames.add(shortName);
baseRecipeRefMap.set(shortName, inst.baseRecipeRef);
}
});
// If no base recipes in German, we're done
if (germanBaseRecipeShortNames.size === 0) {
checkingBaseRecipes = false;
return;
}
// Fetch all base recipes and check their English translations
const untranslated: { shortName: string, name: string }[] = [];
const baseRecipeTranslations = new Map<string, { deName: string, enName: string }>();
for (const shortName of germanBaseRecipeShortNames) {
try {
const response = await fetch(`/api/rezepte/items/${shortName}`);
if (response.ok) {
const recipe = await response.json();
if (!recipe.translations?.en) {
untranslated.push({ shortName, name: recipe.name });
} else {
baseRecipeTranslations.set(shortName, {
deName: recipe.name,
enName: recipe.translations.en.name
});
}
}
} catch (error) {
console.error(`Error fetching base recipe ${shortName}:`, error);
}
}
untranslatedBaseRecipes = untranslated;
checkingBaseRecipes = false;
// Don't proceed if there are untranslated base recipes
if (untranslated.length > 0) {
return;
}
// Merge German base recipe references into editableEnglish
// Update ingredients with English base recipe names
editableEnglish.ingredients = germanData.ingredients.map((germanIng: any, index: number) => {
if (germanIng.type === 'reference' && germanIng.baseRecipeRef) {
const shortName = getShortName(germanIng.baseRecipeRef);
const translation = baseRecipeTranslations.get(shortName);
const englishIng = editableEnglish.ingredients[index];
// If English already has this reference at same position, keep it
if (englishIng?.type === 'reference' && englishIng.baseRecipeRef === germanIng.baseRecipeRef) {
return englishIng;
}
// Otherwise, create new reference with English base recipe name
return translation ? { ...germanIng, name: translation.enName } : germanIng;
} else {
// Regular ingredient section - keep existing English translation if it exists
const englishIng = editableEnglish.ingredients[index];
if (englishIng && englishIng.type !== 'reference') {
return englishIng;
}
// If no English translation exists, use German structure (will be translated later)
return germanIng;
}
});
// Update instructions with English base recipe names
editableEnglish.instructions = germanData.instructions.map((germanInst: any, index: number) => {
if (germanInst.type === 'reference' && germanInst.baseRecipeRef) {
const shortName = getShortName(germanInst.baseRecipeRef);
const translation = baseRecipeTranslations.get(shortName);
const englishInst = editableEnglish.instructions[index];
// If English already has this reference at same position, keep it
if (englishInst?.type === 'reference' && englishInst.baseRecipeRef === germanInst.baseRecipeRef) {
return englishInst;
}
// Otherwise, create new reference with English base recipe name
return translation ? { ...germanInst, name: translation.enName } : germanInst;
} else {
// Regular instruction section - keep existing English translation if it exists
const englishInst = editableEnglish.instructions[index];
if (englishInst && englishInst.type !== 'reference') {
return englishInst;
}
// If no English translation exists, use German structure (will be translated later)
return germanInst;
}
});
// Sync images array - keep existing English alt/caption or initialize empty
editableEnglish.images = germanData.images?.map((germanImg: any, index: number) => {
const existingEnImage = editableEnglish.images?.[index];
return existingEnImage || { alt: '', caption: '' };
}) || [];
}
// Run base recipe check in background (non-blocking)
$effect(() => {
syncBaseRecipeReferences();
});
// Handle auto-translate button click
async function handleAutoTranslate() {
translationState = 'translating';
errorMessage = '';
validationErrors = [];
try {
const response = await fetch('/api/rezepte/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe: germanData,
fields: isEditMode && changedFields.length > 0 ? changedFields : undefined,
oldRecipe: oldRecipeData, // For granular item-level change detection
existingTranslation: englishData, // To merge with unchanged items
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Translation failed');
}
const result = await response.json();
// Capture metadata about what was re-translated
translationMetadata = result.translationMetadata;
// If translating only specific fields, merge with existing translation
// Otherwise use the full translation result
if (isEditMode && changedFields.length > 0 && englishData) {
editableEnglish = { ...englishData, ...result.translatedRecipe };
} else {
editableEnglish = result.translatedRecipe;
}
translationState = 'preview';
} catch (error: any) {
console.error('Translation error:', error);
translationState = 'error';
errorMessage = error.message || 'Translation failed. Please try again.';
}
}
// Handle field changes from TranslationFieldComparison components
function handleFieldChange(value: string, field: string) {
// Special handling for tags (comma-separated string -> array)
if (field === 'tags') {
editableEnglish[field] = value.split(',').map((t: string) => t.trim()).filter((t: string) => t);
}
// Handle nested fields (e.g., baking.temperature, fermentation.bulk)
else if (field.includes('.')) {
const [parent, child] = field.split('.');
if (!editableEnglish[parent]) {
editableEnglish[parent] = {};
}
editableEnglish[parent][child] = value;
} else {
editableEnglish[field] = value;
}
}
// Create add_info object for CreateStepList that references editableEnglish properties
// This allows CreateStepList to modify the values directly
let englishAddInfo = $derived({
get preparation() { return editableEnglish.preparation || ''; },
set preparation(value) { editableEnglish.preparation = value; },
fermentation: {
get bulk() { return editableEnglish.fermentation?.bulk || ''; },
set bulk(value) {
if (!editableEnglish.fermentation) editableEnglish.fermentation = { bulk: '', final: '' };
editableEnglish.fermentation.bulk = value;
},
get final() { return editableEnglish.fermentation?.final || ''; },
set final(value) {
if (!editableEnglish.fermentation) editableEnglish.fermentation = { bulk: '', final: '' };
editableEnglish.fermentation.final = value;
},
},
baking: {
get length() { return editableEnglish.baking?.length || ''; },
set length(value) {
if (!editableEnglish.baking) editableEnglish.baking = { length: '', temperature: '', mode: '' };
editableEnglish.baking.length = value;
},
get temperature() { return editableEnglish.baking?.temperature || ''; },
set temperature(value) {
if (!editableEnglish.baking) editableEnglish.baking = { length: '', temperature: '', mode: '' };
editableEnglish.baking.temperature = value;
},
get mode() { return editableEnglish.baking?.mode || ''; },
set mode(value) {
if (!editableEnglish.baking) editableEnglish.baking = { length: '', temperature: '', mode: '' };
editableEnglish.baking.mode = value;
},
},
get total_time() { return editableEnglish.total_time || ''; },
set total_time(value) { editableEnglish.total_time = value; },
get cooking() { return editableEnglish.cooking || ''; },
set cooking(value) { editableEnglish.cooking = value; },
});
// Handle approval
function handleApprove() {
// Validate required fields
validationErrors = [];
if (!editableEnglish?.name) {
validationErrors.push('English name is required');
}
if (!editableEnglish?.description) {
validationErrors.push('English description is required');
}
if (!editableEnglish?.short_name) {
validationErrors.push('English short_name is required');
}
if (validationErrors.length > 0) {
return;
}
translationState = 'approved';
onapproved?.(new CustomEvent('approved', {
detail: {
translatedRecipe: {
...editableEnglish,
translationStatus: 'approved',
lastTranslated: new Date(),
changedFields: [],
}
}
}));
}
// Handle skip translation
function handleSkip() {
onskipped?.();
}
// Handle cancel
function handleCancel() {
translationState = 'idle';
editableEnglish = {
...germanData,
translationStatus: 'pending',
ingredients: JSON.parse(JSON.stringify(germanData.ingredients || [])),
instructions: JSON.parse(JSON.stringify(germanData.instructions || [])),
images: initializeImagesArray(germanData.images || [])
};
oncancelled?.();
}
// Handle force full retranslation
function handleForceFullRetranslation() {
onforceFullRetranslation?.();
}
// Get status badge color
function getStatusColor(status: string): string {
switch (status) {
case 'approved': return 'var(--nord14)';
case 'pending': return 'var(--nord13)';
case 'needs_update': return 'var(--nord12)';
default: return 'var(--nord9)';
}
}
</script>
<style>
.translation-approval {
margin: 2rem 0;
padding: 1.5rem;
border: 2px solid var(--nord9);
border-radius: 8px;
background: var(--nord1);
}
@media(prefers-color-scheme: light) {
.translation-approval {
background: var(--nord6);
border-color: var(--nord4);
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header h3 {
margin: 0;
color: var(--nord6);
}
@media(prefers-color-scheme: light) {
.header h3 {
color: var(--nord0);
}
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 16px;
font-size: 0.85rem;
font-weight: 600;
color: var(--nord0);
}
.status-pending {
background: var(--nord13);
}
.status-approved {
background: var(--nord14);
}
.status-needs_update {
background: var(--nord12);
}
.translation-preview {
max-width: 1000px;
margin: 1.5rem auto;
}
.field-section {
margin-bottom: 1.5rem;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.list-wrapper {
margin-inline: auto;
display: flex;
flex-direction: row;
max-width: 1000px;
gap: 2rem;
justify-content: center;
margin-bottom: 2rem;
}
@media screen and (max-width: 700px) {
.list-wrapper {
flex-direction: column;
}
}
/* Fix button icon visibility in dark mode */
@media (prefers-color-scheme: dark) {
.list-wrapper :global(svg) {
fill: white !important;
}
.list-wrapper :global(.button_arrow) {
fill: var(--nord4) !important;
}
}
.column-header {
font-weight: 700;
font-size: 1.1rem;
color: var(--nord8);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--nord9);
}
.field-group {
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
flex-wrap: wrap;
}
button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--nord14);
color: var(--nord0);
}
.btn-primary:hover {
background: var(--nord15);
}
.btn-secondary {
background: var(--nord9);
color: var(--nord6);
}
.btn-secondary:hover {
background: var(--nord10);
}
.btn-danger {
background: var(--nord11);
color: var(--nord6);
}
.btn-danger:hover {
background: var(--nord12);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid var(--nord4);
border-top-color: var(--nord14);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-message {
background: var(--nord11);
color: var(--nord6);
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.validation-errors {
background: var(--nord12);
color: var(--nord0);
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.validation-errors ul {
margin: 0.5rem 0 0 0;
padding-left: 1.5rem;
}
.changed-fields {
background: var(--nord13);
color: var(--nord0);
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.changed-fields strong {
font-weight: 700;
}
.idle-state {
text-align: center;
padding: 2rem;
color: var(--nord4);
}
@media(prefers-color-scheme: light) {
.idle-state {
color: var(--nord2);
}
}
.idle-state p {
margin-bottom: 1rem;
font-size: 1.05rem;
}
</style>
<div class="translation-approval">
<div class="header">
<h3>English Translation</h3>
{#if editableEnglish?.translationStatus}
<span class="status-badge status-{editableEnglish.translationStatus}">
{editableEnglish.translationStatus === 'pending' ? 'Pending Approval' : ''}
{editableEnglish.translationStatus === 'approved' ? 'Approved' : ''}
{editableEnglish.translationStatus === 'needs_update' ? 'Needs Update' : ''}
</span>
{/if}
</div>
{#if errorMessage}
<div class="error-message">
<strong>Error:</strong> {errorMessage}
</div>
{/if}
{#if validationErrors.length > 0}
<div class="validation-errors">
<strong>Please fix the following errors:</strong>
<ul>
{#each validationErrors as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}
{#if isEditMode && changedFields.length > 0}
<div class="changed-fields">
<strong>Changed fields:</strong> {changedFields.join(', ')}
<br>
<small>Only these fields will be re-translated if you use auto-translate.</small>
</div>
{/if}
{#if checkingBaseRecipes}
<div style="background: var(--nord9); color: var(--nord6); padding: 1rem; border-radius: 4px; margin-bottom: 1.5rem; text-align: center;">
<p>Checking if referenced base recipes are translated...</p>
</div>
{/if}
{#if untranslatedBaseRecipes.length > 0}
<div style="background: var(--nord12); color: var(--nord0); padding: 1.5rem; border-radius: 4px; margin-bottom: 1.5rem;">
<h4 style="margin-top: 0;">⚠️ Base Recipes Need Translation</h4>
<p>The following base recipes need to be translated to English before you can translate this recipe:</p>
<ul style="margin: 1rem 0;">
{#each untranslatedBaseRecipes as baseRecipe}
<li>
<strong>{baseRecipe.name}</strong>
<a href="/de/edit/{baseRecipe.id}" target="_blank" rel="noopener noreferrer" style="margin-left: 0.5rem; color: var(--nord10);">
Open in new tab →
</a>
</li>
{/each}
</ul>
<p style="margin-bottom: 0;">
<button class="btn-secondary" onclick={syncBaseRecipeReferences}>
Re-check Base Recipes
</button>
</p>
</div>
{/if}
{#if translationState === 'idle'}
<div style="background: var(--nord13); color: var(--nord0); padding: 1rem; border-radius: 4px; margin-bottom: 1.5rem; text-align: center;">
<strong>Preview (Not yet translated)</strong>
<p style="margin: 0.5rem 0;">The structure below shows what will be translated. Click "Auto-translate" to generate English translation.</p>
</div>
{/if}
{#if translationState === 'translating'}
<div class="idle-state">
<p>
<span class="loading-spinner"></span>
Translating recipe...
</p>
</div>
{/if}
{#if translationState === 'idle' || translationState === 'preview' || translationState === 'approved'}
<div class="translation-preview">
<h3 style="margin-bottom: 1.5rem; color: var(--nord8);">🇬🇧 English Translation</h3>
<!-- Basic Fields -->
<div class="field-section">
<TranslationFieldComparison
label="Name"
germanValue={germanData.name}
englishValue={editableEnglish?.name || ''}
fieldName="name"
readonly={false}
onchange={(value) => handleFieldChange(value, 'name')}
/>
</div>
<div class="field-section">
<TranslationFieldComparison
label="Short Name (URL)"
germanValue={germanData.short_name}
englishValue={editableEnglish?.short_name || ''}
fieldName="short_name"
readonly={false}
onchange={(value) => handleFieldChange(value, 'short_name')}
/>
</div>
<div class="field-section">
<TranslationFieldComparison
label="Description"
germanValue={germanData.description}
englishValue={editableEnglish?.description || ''}
fieldName="description"
readonly={false}
multiline={true}
onchange={(value) => handleFieldChange(value, 'description')}
/>
</div>
<div class="field-section">
<TranslationFieldComparison
label="Category"
germanValue={germanData.category}
englishValue={editableEnglish?.category || ''}
fieldName="category"
readonly={false}
onchange={(value) => handleFieldChange(value, 'category')}
/>
</div>
{#if editableEnglish?.tags}
<div class="field-section">
<TranslationFieldComparison
label="Tags"
germanValue={germanData.tags?.join(', ') || ''}
englishValue={editableEnglish.tags.join(', ')}
fieldName="tags"
readonly={false}
onchange={(value) => handleFieldChange(value, 'tags')}
/>
</div>
{/if}
{#if editableEnglish?.preamble !== undefined}
<div class="field-section">
<TranslationFieldComparison
label="Preamble"
germanValue={germanData.preamble || ''}
englishValue={editableEnglish.preamble}
fieldName="preamble"
readonly={false}
multiline={true}
onchange={(value) => handleFieldChange(value, 'preamble')}
/>
</div>
{/if}
{#if editableEnglish?.note !== undefined}
<div class="field-section">
<TranslationFieldComparison
label="Note"
germanValue={germanData.note || ''}
englishValue={editableEnglish.note}
fieldName="note"
readonly={false}
multiline={true}
onchange={(value) => handleFieldChange(value, 'note')}
/>
</div>
{/if}
{#if editableEnglish?.portions !== undefined}
<div class="field-section">
<TranslationFieldComparison
label="Portions"
germanValue={germanData.portions || ''}
englishValue={editableEnglish.portions}
fieldName="portions"
readonly={false}
onchange={(value) => handleFieldChange(value, 'portions')}
/>
</div>
{/if}
<!-- Images Section -->
{#if germanData.images && germanData.images.length > 0}
<div class="field-section" style="background-color: var(--nord13); padding: 1rem; border-radius: 5px; margin-top: 1.5rem;">
<h4 style="margin-top: 0; color: var(--nord0);">🖼️ Images - English Alt Texts & Captions</h4>
{#each germanData.images as germanImage, i}
{#if editableEnglish.images && editableEnglish.images[i]}
<div style="background-color: white; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; border: 2px solid var(--nord9);">
<div style="display: flex; gap: 1rem; align-items: start;">
<img
src="https://bocken.org/static/rezepte/thumb/{germanImage.mediapath}"
alt={germanImage.alt || 'Recipe image'}
style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;"
/>
<div style="flex: 1;">
<p style="margin: 0 0 0.5rem 0; font-size: 0.85rem; color: var(--nord3);"><strong>Image {i + 1}:</strong> {germanImage.mediapath}</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.75rem;">
<div>
<label for="german-alt-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇩🇪 German Alt-Text:</label>
<input
id="german-alt-{i}"
type="text"
value={germanImage.alt || ''}
disabled
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord4); border-radius: 3px; background-color: var(--nord5); color: var(--nord2); font-size: 0.85rem;"
/>
</div>
<div>
<label for="english-alt-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇬🇧 English Alt-Text:</label>
<input
id="english-alt-{i}"
type="text"
bind:value={editableEnglish.images[i].alt}
placeholder="English image description for screen readers"
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord8); border-radius: 3px; font-size: 0.85rem;"
/>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div>
<label for="german-caption-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇩🇪 German Caption:</label>
<input
id="german-caption-{i}"
type="text"
value={germanImage.caption || ''}
disabled
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord4); border-radius: 3px; background-color: var(--nord5); color: var(--nord2); font-size: 0.85rem;"
/>
</div>
<div>
<label for="english-caption-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇬🇧 English Caption:</label>
<input
id="english-caption-{i}"
type="text"
bind:value={editableEnglish.images[i].caption}
placeholder="English caption (optional)"
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord8); border-radius: 3px; font-size: 0.85rem;"
/>
</div>
</div>
<div style="margin-top: 0.75rem;">
<GenerateAltTextButton shortName={germanData.short_name} imageIndex={i} />
</div>
</div>
</div>
</div>
{/if}
{/each}
</div>
{/if}
<!-- Ingredients and Instructions in two-column layout -->
{#if editableEnglish?.ingredients || editableEnglish?.instructions}
<div class="list-wrapper">
<div>
{#if editableEnglish?.ingredients}
<CreateIngredientList bind:ingredients={editableEnglish.ingredients} lang="en" />
{/if}
</div>
<div>
{#if editableEnglish?.instructions && englishAddInfo}
<CreateStepList bind:instructions={editableEnglish.instructions} add_info={englishAddInfo} lang="en" />
{/if}
</div>
</div>
{/if}
{#if editableEnglish?.addendum !== undefined}
<div class="field-section">
<TranslationFieldComparison
label="Addendum"
germanValue={germanData.addendum || ''}
englishValue={editableEnglish.addendum}
fieldName="addendum"
readonly={false}
multiline={true}
onchange={(value) => handleFieldChange(value, 'addendum')}
/>
</div>
{/if}
</div>
<div class="actions">
{#if translationState === 'idle'}
<button class="btn-danger" onclick={handleCancel}>
Cancel
</button>
<button class="btn-secondary" onclick={handleSkip}>
Skip Translation
</button>
<button class="btn-primary" onclick={handleAutoTranslate} disabled={untranslatedBaseRecipes.length > 0}>
{#if untranslatedBaseRecipes.length > 0}
Translate base recipes first
{:else}
Auto-translate
{/if}
</button>
{:else if translationState !== 'approved'}
<button class="btn-danger" onclick={handleCancel}>
Cancel
</button>
<button class="btn-secondary" onclick={handleForceFullRetranslation}>
Vollständig neu übersetzen
</button>
<button class="btn-secondary" onclick={handleAutoTranslate}>
Re-translate
</button>
<button class="btn-primary" onclick={handleApprove}>
Approve Translation
</button>
{:else}
<span style="color: var(--nord14); font-weight: 700;">✓ Translation Approved</span>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,148 @@
<script lang="ts">
interface Props {
label: string;
germanValue: string;
englishValue: string;
fieldName: string;
readonly?: boolean;
multiline?: boolean;
onchange?: (value: string) => void;
}
let {
label,
germanValue,
englishValue,
fieldName,
readonly = false,
multiline = false,
onchange
}: Props = $props();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
onchange?.(target.value);
}
</script>
<style>
.field-comparison {
margin-bottom: 1rem;
}
.field-label {
font-weight: 600;
color: var(--nord4);
margin-bottom: 0.5rem;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@media(prefers-color-scheme: light) {
.field-label {
color: var(--nord2);
}
}
.field-value {
padding: 0.75rem;
background: var(--nord0);
border-radius: 4px;
color: var(--nord6);
border: 1px solid var(--nord3);
min-height: 3rem;
}
@media(prefers-color-scheme: light) {
.field-value {
background: var(--nord5);
color: var(--nord0);
border-color: var(--nord3);
}
}
.field-value.readonly {
opacity: 0.8;
}
input.field-value,
textarea.field-value {
width: 100%;
font-family: inherit;
font-size: 1rem;
box-sizing: border-box;
resize: vertical;
}
input.field-value:focus,
textarea.field-value:focus {
outline: 2px solid var(--nord14);
border-color: var(--nord14);
}
textarea.field-value {
min-height: 6rem;
}
.readonly-text {
white-space: pre-wrap;
word-wrap: break-word;
}
:global(.readonly-text strong) {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
color: var(--nord8);
}
:global(.readonly-text strong:first-child) {
margin-top: 0;
}
:global(.readonly-text ul),
:global(.readonly-text ol) {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
:global(.readonly-text li) {
margin: 0.25rem 0;
color: var(--nord4);
}
@media(prefers-color-scheme: light) {
:global(.readonly-text strong) {
color: var(--nord10);
}
:global(.readonly-text li) {
color: var(--nord2);
}
}
</style>
<div class="field-comparison">
<div class="field-label">{label}</div>
{#if readonly}
<div class="field-value readonly readonly-text">
{germanValue || '(empty)'}
</div>
{:else if multiline}
<textarea
class="field-value"
value={englishValue}
oninput={handleInput}
placeholder="Enter {label.toLowerCase()}..."
></textarea>
{:else}
<input
type="text"
class="field-value"
value={englishValue}
oninput={handleInput}
placeholder="Enter {label.toLowerCase()}..."
/>
{/if}
</div>
+14
View File
@@ -0,0 +1,14 @@
/**
* @param {KeyboardEvent} event
* @param {string} key
* @param {boolean} needsctrl
* @param {() => void} fn
*/
export function do_on_key(event, key, needsctrl, fn){
if(event.key == key){
if(needsctrl && !event.ctrlKey){
return
}
fn()
}
}