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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(/­|/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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user