All checks were successful
CI / update (push) Successful in 13s
Refactor recipe add/edit routes from client-side fetch to proper SvelteKit form actions with progressive enhancement and comprehensive security improvements. **Security Enhancements:** - Implement 5-layer image validation (file size, MIME type, extension, magic bytes, Sharp structure) - Replace insecure base64 JSON encoding with FormData for file uploads - Add file-type@19 dependency for magic bytes validation - Validate actual file type via magic bytes to prevent file type spoofing **Progressive Enhancement:** - Forms now work without JavaScript using native browser submission - Add use:enhance for improved client-side UX when JS is available - Serialize complex nested data (ingredients/instructions) via JSON in hidden fields - Translation workflow integrated via programmatic form submission **Bug Fixes:** - Add type="button" to all interactive buttons in CreateIngredientList and CreateStepList to prevent premature form submission when clicking on ingredients/steps - Fix SSR errors by using season_local state instead of get_season() DOM query - Fix redirect handling in form actions (redirects were being caught as errors) - Fix TranslationApproval to handle recipes without images using null-safe checks - Add reactive effect to sync editableEnglish.images with germanData.images length - Detect and hide 150x150 placeholder images in CardAdd component **Features:** - Make image uploads optional for recipe creation (use placeholder based on short_name) - Handle three image scenarios in edit: keep existing, upload new, rename on short_name change - Automatic image file renaming across full/thumb/placeholder directories when short_name changes - Change detection for partial translation updates in edit mode **Technical Changes:** - Create imageValidation.ts utility with comprehensive file validation - Create recipeFormHelpers.ts for data extraction, validation, and serialization - Refactor /api/rezepte/img/add endpoint to use FormData instead of base64 - Update CardAdd component to upload via FormData immediately with proper error handling - Use Image API for placeholder detection (avoids CORS issues with fetch)
919 lines
28 KiB
Svelte
919 lines
28 KiB
Svelte
<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/do_on_key.js'
|
|
import { portions } from '$lib/js/portions_store.js'
|
|
import BaseRecipeSelector from '$lib/components/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 || '',
|
|
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>
|
|
<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}
|
|
/>
|