feat(recipes): client-side image editor + modernize /add to match /edit
CI / update (push) Has been cancelled
CI / update (push) Has been cancelled
Add a from-scratch photo editor (crop, max-resolution scale-to-fit, WebP quality with live final-size + dimensions readout) that opens on image pick in the recipe add/edit flow. Conversion uses the browser's canvas WebP encoder (sharp can't run client-side); crop, scale and the size readout are built by hand. Server now stores the client WebP full image byte-for-byte (passthrough) so the on-disk file matches the user's chosen quality/size; sharp still derives the 800px thumb and OKLAB colour. Non-WebP uploads keep the old q90 re-encode fallback. Rework /add to reuse EditTitleImgParallax (parallax hero + titleExtras/below-hero layout, shape-tile Backform, SaveFab + optional translation), replacing the antiquated CardAdd card. Move the edit/remove image controls into the hero, below the fixed header. Delete now-dead CardAdd and RecipeEditor.
This commit is contained in:
@@ -173,7 +173,6 @@ Generated: 2025-11-18
|
|||||||
- `EditButton.svelte` - Edit button (floating)
|
- `EditButton.svelte` - Edit button (floating)
|
||||||
- `FavoriteButton.svelte` - Toggle favorite
|
- `FavoriteButton.svelte` - Toggle favorite
|
||||||
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
|
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
|
||||||
- `CardAdd.svelte` - Add recipe card placeholder
|
|
||||||
- `FormSection.svelte` - Styled form section wrapper
|
- `FormSection.svelte` - Styled form section wrapper
|
||||||
- `Header.svelte` - Page header
|
- `Header.svelte` - Page header
|
||||||
- `UserHeader.svelte` - User-specific header
|
- `UserHeader.svelte` - User-specific header
|
||||||
@@ -190,7 +189,6 @@ Generated: 2025-11-18
|
|||||||
|
|
||||||
#### Recipe-Specific Components
|
#### Recipe-Specific Components
|
||||||
- `Recipes.svelte` - Recipe list display
|
- `Recipes.svelte` - Recipe list display
|
||||||
- `RecipeEditor.svelte` - Recipe editing form
|
|
||||||
- `RecipeNote.svelte` - Recipe notes display
|
- `RecipeNote.svelte` - Recipe notes display
|
||||||
- `EditRecipe.svelte` - Edit recipe modal
|
- `EditRecipe.svelte` - Edit recipe modal
|
||||||
- `EditRecipeNote.svelte` - Edit recipe notes
|
- `EditRecipeNote.svelte` - Edit recipe notes
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.94.1",
|
"version": "1.95.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,434 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
|
|
||||||
import Cross from '$lib/assets/icons/Cross.svelte'
|
|
||||||
import { toast } from '$lib/js/toast.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)) {
|
|
||||||
toast.error('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
toast.error(`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;
|
|
||||||
width: var(--card-width);
|
|
||||||
aspect-ratio: 4/7;
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
background-size: contain;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: end;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
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: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.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: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.img_label_wrapper:hover .delete{
|
|
||||||
opacity: 100%;
|
|
||||||
}
|
|
||||||
.img_label svg{
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
fill: white;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.delete{
|
|
||||||
cursor: pointer;
|
|
||||||
all: unset;
|
|
||||||
position: absolute;
|
|
||||||
top:2rem;
|
|
||||||
left: 2rem;
|
|
||||||
opacity: 0%;
|
|
||||||
z-index: 4;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.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: var(--transition-fast);
|
|
||||||
position: absolute;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
top:-0.5em;
|
|
||||||
right:-0.5em;
|
|
||||||
padding: 0.25em;
|
|
||||||
background-color: var(--nord6);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
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: var(--transition-fast);
|
|
||||||
}
|
|
||||||
.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: var(--transition-fast);
|
|
||||||
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: var(--radius-pill);
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
|
|
||||||
}
|
|
||||||
.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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Cross from '$lib/assets/icons/Cross.svelte';
|
import Cross from '$lib/assets/icons/Cross.svelte';
|
||||||
|
import ImageEditor from '$lib/components/recipes/ImageEditor.svelte';
|
||||||
import { toast } from '$lib/js/toast.svelte';
|
import { toast } from '$lib/js/toast.svelte';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -53,9 +54,34 @@
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openEditor(file);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo editor (crop / scale / webp quality) state
|
||||||
|
let editorFile = $state<File | null>(null);
|
||||||
|
let editorOpen = $state(false);
|
||||||
|
|
||||||
|
function openEditor(file: File) {
|
||||||
|
editorFile = file;
|
||||||
|
editorOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
editorOpen = false;
|
||||||
|
editorFile = null;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditorApply(file: File, url: string) {
|
||||||
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
|
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
|
||||||
image_preview_url = URL.createObjectURL(file);
|
|
||||||
selected_image_file = file;
|
selected_image_file = file;
|
||||||
|
image_preview_url = url;
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCurrentImage() {
|
||||||
|
if (selected_image_file) openEditor(selected_image_file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSelectedImage() {
|
function clearSelectedImage() {
|
||||||
@@ -129,15 +155,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{#if selected_image_file}
|
{#if selected_image_file}
|
||||||
|
<div class="img-controls">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="clear-img"
|
class="img-btn"
|
||||||
|
onclick={editCurrentImage}
|
||||||
|
title="Bild bearbeiten"
|
||||||
|
aria-label="Bild bearbeiten"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="img-btn danger"
|
||||||
onclick={clearSelectedImage}
|
onclick={clearSelectedImage}
|
||||||
title="Auswahl verwerfen"
|
title="Auswahl verwerfen"
|
||||||
aria-label="Auswahl verwerfen"
|
aria-label="Auswahl verwerfen"
|
||||||
>
|
>
|
||||||
<Cross fill="white" width="1.25rem" height="1.25rem" />
|
<Cross fill="white" width="1.15rem" height="1.15rem" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
@@ -215,6 +256,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if editorOpen && editorFile}
|
||||||
|
<ImageEditor file={editorFile} onApply={handleEditorApply} onCancel={closeEditor} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.section {
|
.section {
|
||||||
--scale: 0.3;
|
--scale: 0.3;
|
||||||
@@ -312,10 +357,18 @@
|
|||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-img {
|
/* Edit / remove controls — top-right of the image, offset below the fixed
|
||||||
|
site header (height 3rem, top max(12px, safe-area+4px)) so the nav never
|
||||||
|
obstructs them. */
|
||||||
|
.img-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(1rem + env(safe-area-inset-top, 0px));
|
top: calc(max(12px, env(safe-area-inset-top, 0px) + 4px) + 3rem + 1rem);
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.img-btn {
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
border: none;
|
border: none;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
@@ -324,17 +377,26 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 5;
|
|
||||||
transition:
|
transition:
|
||||||
transform 150ms ease,
|
transform 150ms ease,
|
||||||
background 150ms ease;
|
background 150ms ease;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.clear-img:hover,
|
.img-btn svg {
|
||||||
.clear-img:focus-visible {
|
width: 1.15rem;
|
||||||
background: var(--red);
|
height: 1.15rem;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
.img-btn:hover,
|
||||||
|
.img-btn:focus-visible {
|
||||||
|
background: var(--color-primary);
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
.img-btn.danger:hover,
|
||||||
|
.img-btn.danger:focus-visible {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
.file-input {
|
.file-input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -0,0 +1,797 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
loadBitmap,
|
||||||
|
renderToBlob,
|
||||||
|
fitWithin,
|
||||||
|
blobToFile,
|
||||||
|
formatBytes,
|
||||||
|
type CropRect
|
||||||
|
} from '$lib/js/imageEdit';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
file: File;
|
||||||
|
shortName?: string;
|
||||||
|
onApply: (file: File, url: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { file, shortName = '', onApply, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
const MIN_CROP = 24; // minimum crop edge, source px
|
||||||
|
|
||||||
|
const RATIOS = [
|
||||||
|
{ key: 'free', label: 'Frei' },
|
||||||
|
{ key: 'orig', label: 'Original' },
|
||||||
|
{ key: '1:1', label: '1:1', value: 1 },
|
||||||
|
{ key: '4:3', label: '4:3', value: 4 / 3 },
|
||||||
|
{ key: '3:2', label: '3:2', value: 3 / 2 },
|
||||||
|
{ key: '16:9', label: '16:9', value: 16 / 9 }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RES_PRESETS = [1000, 1500, 2000, 0]; // 0 = Original
|
||||||
|
|
||||||
|
let bitmap = $state<ImageBitmap | null>(null);
|
||||||
|
let imgW = $state(0);
|
||||||
|
let imgH = $state(0);
|
||||||
|
let loadError = $state('');
|
||||||
|
|
||||||
|
let crop = $state<CropRect>({ x: 0, y: 0, w: 0, h: 0 });
|
||||||
|
let ratioMode = $state<string>('free');
|
||||||
|
let maxRes = $state(2000);
|
||||||
|
let quality = $state(92);
|
||||||
|
|
||||||
|
// Live-encode output
|
||||||
|
let outBlob = $state<Blob | null>(null);
|
||||||
|
let outUrl = $state('');
|
||||||
|
let outW = $state(0);
|
||||||
|
let outH = $state(0);
|
||||||
|
let encoding = $state(false);
|
||||||
|
|
||||||
|
// Stage measurement
|
||||||
|
let stageW = $state(0);
|
||||||
|
let stageH = $state(0);
|
||||||
|
let stageCanvas = $state<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const activeRatio = $derived.by(() => {
|
||||||
|
const r = RATIOS.find((x) => x.key === ratioMode);
|
||||||
|
if (!r) return null;
|
||||||
|
if (r.key === 'orig') return imgH ? imgW / imgH : null;
|
||||||
|
return 'value' in r ? r.value : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit the source image into the available stage area (display pixels).
|
||||||
|
const displayScale = $derived.by(() => {
|
||||||
|
if (!imgW || !imgH || !stageW || !stageH) return 1;
|
||||||
|
const availW = Math.max(1, stageW - 24);
|
||||||
|
const availH = Math.max(1, stageH - 24);
|
||||||
|
return Math.min(availW / imgW, availH / imgH);
|
||||||
|
});
|
||||||
|
const dispW = $derived(Math.round(imgW * displayScale));
|
||||||
|
const dispH = $derived(Math.round(imgH * displayScale));
|
||||||
|
|
||||||
|
function clamp(v: number, lo: number, hi: number) {
|
||||||
|
return Math.max(lo, Math.min(hi, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const bm = await loadBitmap(file);
|
||||||
|
if (cancelled) {
|
||||||
|
bm.close?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bitmap = bm;
|
||||||
|
imgW = bm.width;
|
||||||
|
imgH = bm.height;
|
||||||
|
crop = { x: 0, y: 0, w: bm.width, h: bm.height };
|
||||||
|
} catch {
|
||||||
|
loadError = 'Bild konnte nicht geladen werden.';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw the source onto the display canvas whenever it or the layout changes.
|
||||||
|
$effect(() => {
|
||||||
|
const cv = stageCanvas;
|
||||||
|
const bm = bitmap;
|
||||||
|
const w = dispW;
|
||||||
|
const h = dispH;
|
||||||
|
if (!cv || !bm || w <= 0 || h <= 0) return;
|
||||||
|
cv.width = w;
|
||||||
|
cv.height = h;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.drawImage(bm, 0, 0, imgW, imgH, 0, 0, w, h);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounced live encode — runs whenever crop / resolution / quality change.
|
||||||
|
let encodeToken = 0;
|
||||||
|
$effect(() => {
|
||||||
|
const bm = bitmap;
|
||||||
|
if (!bm) return;
|
||||||
|
const c = { ...crop };
|
||||||
|
const mr = maxRes;
|
||||||
|
const q = quality;
|
||||||
|
const token = ++encodeToken;
|
||||||
|
encoding = true;
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const blob = await renderToBlob(bm, c, mr, q);
|
||||||
|
if (token !== encodeToken) return;
|
||||||
|
const size = fitWithin(c.w, c.h, mr);
|
||||||
|
if (outUrl) URL.revokeObjectURL(outUrl);
|
||||||
|
outBlob = blob;
|
||||||
|
outUrl = URL.createObjectURL(blob);
|
||||||
|
outW = size.w;
|
||||||
|
outH = size.h;
|
||||||
|
} catch {
|
||||||
|
/* transient encode failure — next change retries */
|
||||||
|
} finally {
|
||||||
|
if (token === encodeToken) encoding = false;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
if (outUrl) URL.revokeObjectURL(outUrl);
|
||||||
|
bitmap?.close?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Crop drag handling ---
|
||||||
|
type Drag = { handle: string; hx: number; hy: number; px: number; py: number; start: CropRect };
|
||||||
|
let drag: Drag | null = null;
|
||||||
|
|
||||||
|
function startDrag(e: PointerEvent, handle: string, hx: number, hy: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||||
|
drag = { handle, hx, hy, px: e.clientX, py: e.clientY, start: { ...crop } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!drag || displayScale === 0) return;
|
||||||
|
const ddx = (e.clientX - drag.px) / displayScale;
|
||||||
|
const ddy = (e.clientY - drag.py) / displayScale;
|
||||||
|
const s = drag.start;
|
||||||
|
|
||||||
|
if (drag.handle === 'move') {
|
||||||
|
crop = {
|
||||||
|
x: clamp(s.x + ddx, 0, imgW - s.w),
|
||||||
|
y: clamp(s.y + ddy, 0, imgH - s.h),
|
||||||
|
w: s.w,
|
||||||
|
h: s.h
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = s.x;
|
||||||
|
let top = s.y;
|
||||||
|
let right = s.x + s.w;
|
||||||
|
let bottom = s.y + s.h;
|
||||||
|
if (drag.hx === 1) right = s.x + s.w + ddx;
|
||||||
|
else if (drag.hx === -1) left = s.x + ddx;
|
||||||
|
if (drag.hy === 1) bottom = s.y + s.h + ddy;
|
||||||
|
else if (drag.hy === -1) top = s.y + ddy;
|
||||||
|
|
||||||
|
const r = activeRatio;
|
||||||
|
if (r) {
|
||||||
|
if (drag.hx !== 0 && drag.hy !== 0) {
|
||||||
|
const nw = Math.max(MIN_CROP, right - left);
|
||||||
|
const nh = nw / r;
|
||||||
|
if (drag.hy === 1) bottom = top + nh;
|
||||||
|
else top = bottom - nh;
|
||||||
|
} else if (drag.hx !== 0) {
|
||||||
|
const cy = s.y + s.h / 2;
|
||||||
|
const nh = Math.max(MIN_CROP, right - left) / r;
|
||||||
|
top = cy - nh / 2;
|
||||||
|
bottom = cy + nh / 2;
|
||||||
|
} else if (drag.hy !== 0) {
|
||||||
|
const cx = s.x + s.w / 2;
|
||||||
|
const nw = Math.max(MIN_CROP, bottom - top) * r;
|
||||||
|
left = cx - nw / 2;
|
||||||
|
right = cx + nw / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
left = Math.max(0, left);
|
||||||
|
top = Math.max(0, top);
|
||||||
|
right = Math.min(imgW, right);
|
||||||
|
bottom = Math.min(imgH, bottom);
|
||||||
|
if (right - left < MIN_CROP) {
|
||||||
|
if (drag.hx === -1) left = right - MIN_CROP;
|
||||||
|
else right = left + MIN_CROP;
|
||||||
|
}
|
||||||
|
if (bottom - top < MIN_CROP) {
|
||||||
|
if (drag.hy === -1) top = bottom - MIN_CROP;
|
||||||
|
else bottom = top + MIN_CROP;
|
||||||
|
}
|
||||||
|
left = Math.max(0, left);
|
||||||
|
top = Math.max(0, top);
|
||||||
|
right = Math.min(imgW, right);
|
||||||
|
bottom = Math.min(imgH, bottom);
|
||||||
|
|
||||||
|
crop = { x: left, y: top, w: right - left, h: bottom - top };
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
// Pointer capture is released implicitly on pointerup.
|
||||||
|
drag = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRatio(key: string) {
|
||||||
|
ratioMode = key;
|
||||||
|
const r = RATIOS.find((x) => x.key === key);
|
||||||
|
const value = r && r.key === 'orig' ? imgW / imgH : r && 'value' in r ? r.value : null;
|
||||||
|
if (!value) return; // 'free' keeps the current crop
|
||||||
|
// Fit a centred rect of this ratio inside the current crop.
|
||||||
|
const cx = crop.x + crop.w / 2;
|
||||||
|
const cy = crop.y + crop.h / 2;
|
||||||
|
let nw = crop.w;
|
||||||
|
let nh = nw / value;
|
||||||
|
if (nh > crop.h) {
|
||||||
|
nh = crop.h;
|
||||||
|
nw = nh * value;
|
||||||
|
}
|
||||||
|
nw = Math.min(nw, imgW);
|
||||||
|
nh = Math.min(nh, imgH);
|
||||||
|
crop = {
|
||||||
|
x: clamp(cx - nw / 2, 0, imgW - nw),
|
||||||
|
y: clamp(cy - nh / 2, 0, imgH - nh),
|
||||||
|
w: nw,
|
||||||
|
h: nh
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCrop() {
|
||||||
|
ratioMode = 'free';
|
||||||
|
crop = { x: 0, y: 0, w: imgW, h: imgH };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
if (!outBlob || !outUrl) return;
|
||||||
|
const url = outUrl;
|
||||||
|
const f = blobToFile(outBlob, shortName);
|
||||||
|
outUrl = ''; // hand the object URL off to the caller; don't revoke it
|
||||||
|
onApply(f, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handles = [
|
||||||
|
{ key: 'nw', hx: -1, hy: -1 },
|
||||||
|
{ key: 'n', hx: 0, hy: -1 },
|
||||||
|
{ key: 'ne', hx: 1, hy: -1 },
|
||||||
|
{ key: 'e', hx: 1, hy: 0 },
|
||||||
|
{ key: 'se', hx: 1, hy: 1 },
|
||||||
|
{ key: 's', hx: 0, hy: 1 },
|
||||||
|
{ key: 'sw', hx: -1, hy: 1 },
|
||||||
|
{ key: 'w', hx: -1, hy: 0 }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Bild bearbeiten"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<button type="button" class="scrim" aria-label="Schliessen" onclick={onCancel}></button>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h2>Bild bearbeiten</h2>
|
||||||
|
<button type="button" class="ghost" onclick={onCancel} aria-label="Abbrechen">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<!-- Stage -->
|
||||||
|
<div class="stage" bind:clientWidth={stageW} bind:clientHeight={stageH}>
|
||||||
|
{#if loadError}
|
||||||
|
<p class="stage-msg">{loadError}</p>
|
||||||
|
{:else if !bitmap}
|
||||||
|
<p class="stage-msg">Lade Bild…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="frame" style:width="{dispW}px" style:height="{dispH}px">
|
||||||
|
<canvas bind:this={stageCanvas}></canvas>
|
||||||
|
<div
|
||||||
|
class="crop"
|
||||||
|
style:left="{crop.x * displayScale}px"
|
||||||
|
style:top="{crop.y * displayScale}px"
|
||||||
|
style:width="{crop.w * displayScale}px"
|
||||||
|
style:height="{crop.h * displayScale}px"
|
||||||
|
role="application"
|
||||||
|
aria-label="Zuschneidebereich verschieben"
|
||||||
|
onpointerdown={(e) => startDrag(e, 'move', 0, 0)}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={endDrag}
|
||||||
|
onpointercancel={endDrag}
|
||||||
|
>
|
||||||
|
<span class="third v1"></span>
|
||||||
|
<span class="third v2"></span>
|
||||||
|
<span class="third h1"></span>
|
||||||
|
<span class="third h2"></span>
|
||||||
|
{#each handles as h (h.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="handle h-{h.key}"
|
||||||
|
aria-label="Ziehpunkt {h.key}"
|
||||||
|
onpointerdown={(e) => startDrag(e, h.key, h.hx, h.hy)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="rail">
|
||||||
|
<div class="preview">
|
||||||
|
<div class="preview-img" class:busy={encoding}>
|
||||||
|
{#if outUrl}
|
||||||
|
<!-- svelte-ignore a11y_missing_attribute -->
|
||||||
|
<img src={outUrl} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<dl class="stats">
|
||||||
|
<div><dt>Auflösung</dt><dd>{outW || '—'} × {outH || '—'}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Dateigrösse</dt>
|
||||||
|
<dd class="size">{outBlob ? formatBytes(outBlob.size) : '—'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="group">
|
||||||
|
<legend>Seitenverhältnis</legend>
|
||||||
|
<div class="chips">
|
||||||
|
{#each RATIOS as r (r.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
class:active={ratioMode === r.key}
|
||||||
|
onclick={() => selectRatio(r.key)}>{r.label}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="group">
|
||||||
|
<legend>Max. Auflösung</legend>
|
||||||
|
<div class="chips">
|
||||||
|
{#each RES_PRESETS as p (p)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
class:active={maxRes === p}
|
||||||
|
onclick={() => (maxRes = p)}>{p === 0 ? 'Original' : p}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<label class="custom">
|
||||||
|
<span>Eigene Kante</span>
|
||||||
|
<input type="number" min="0" step="50" bind:value={maxRes} />
|
||||||
|
<span class="unit">px</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="group">
|
||||||
|
<legend>WebP-Qualität</legend>
|
||||||
|
<div class="quality">
|
||||||
|
<input type="range" min="1" max="100" step="1" bind:value={quality} />
|
||||||
|
<output>{quality}</output>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="button" class="reset" onclick={resetCrop}>Zuschnitt zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="panel-foot">
|
||||||
|
<button type="button" class="btn ghost-btn" onclick={onCancel}>Abbrechen</button>
|
||||||
|
<button type="button" class="btn primary" disabled={!outBlob} onclick={apply}>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: clamp(0px, 2vw, 1.5rem);
|
||||||
|
}
|
||||||
|
.scrim {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 14, 20, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
height: min(760px, 100%);
|
||||||
|
max-height: 100%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-head,
|
||||||
|
.panel-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg, 1.2rem);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.panel-foot {
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.ghost:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage */
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 0;
|
||||||
|
background:
|
||||||
|
repeating-conic-gradient(var(--color-bg-secondary) 0% 25%, transparent 0% 50%) 50% / 24px 24px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stage-msg {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.frame {
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.frame canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.crop {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||||
|
cursor: move;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.third {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.third.v1,
|
||||||
|
.third.v2 {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
.third.v1 {
|
||||||
|
left: 33.33%;
|
||||||
|
}
|
||||||
|
.third.v2 {
|
||||||
|
left: 66.66%;
|
||||||
|
}
|
||||||
|
.third.h1,
|
||||||
|
.third.h2 {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.third.h1 {
|
||||||
|
top: 33.33%;
|
||||||
|
}
|
||||||
|
.third.h2 {
|
||||||
|
top: 66.66%;
|
||||||
|
}
|
||||||
|
.handle {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.h-nw {
|
||||||
|
top: -7px;
|
||||||
|
left: -7px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
.h-ne {
|
||||||
|
top: -7px;
|
||||||
|
right: -7px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
.h-se {
|
||||||
|
bottom: -7px;
|
||||||
|
right: -7px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
.h-sw {
|
||||||
|
bottom: -7px;
|
||||||
|
left: -7px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
.h-n {
|
||||||
|
top: -7px;
|
||||||
|
left: calc(50% - 7px);
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
.h-s {
|
||||||
|
bottom: -7px;
|
||||||
|
left: calc(50% - 7px);
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
.h-e {
|
||||||
|
right: -7px;
|
||||||
|
top: calc(50% - 7px);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
.h-w {
|
||||||
|
left: -7px;
|
||||||
|
top: calc(50% - 7px);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rail */
|
||||||
|
.rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.preview-img {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
.preview-img.busy {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.preview-img img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stats dt {
|
||||||
|
font-size: var(--text-sm, 0.8rem);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.stats dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-md, 1rem);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stats dd.size {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.group legend {
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--text-sm, 0.8rem);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm, 0.85rem);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.chip.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
.custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: var(--text-sm, 0.85rem);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.custom input {
|
||||||
|
width: 6ch;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.custom .unit {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.quality {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.quality input[type='range'] {
|
||||||
|
flex: 1;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.quality output {
|
||||||
|
min-width: 2.5ch;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.reset {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm, 0.85rem);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
.reset:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
.ghost-btn {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.ghost-btn:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
.primary:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.panel {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.rail {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
max-height: 45dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
|
|
||||||
import MediaScroller from '$lib/components/recipes/MediaScroller.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({}),
|
|
||||||
seasonRanges = $bindable([]),
|
|
||||||
ingredients = $bindable([]),
|
|
||||||
instructions = $bindable([])
|
|
||||||
}: {
|
|
||||||
card_data?: any,
|
|
||||||
seasonRanges?: any[],
|
|
||||||
ingredients?: any[],
|
|
||||||
instructions?: any[]
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let short_name = $state('');
|
|
||||||
let password = $state('');
|
|
||||||
let datecreated = $state(new Date());
|
|
||||||
let datemodified = $state(datecreated);
|
|
||||||
let result = $state('');
|
|
||||||
let image_preview_url = $state('');
|
|
||||||
let selected_image_file = $state<File | null>(null);
|
|
||||||
|
|
||||||
async function doPost () {
|
|
||||||
const res = await fetch('/api/add', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
recipe: {
|
|
||||||
seasonRanges: seasonRanges,
|
|
||||||
...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: var(--radius-pill);
|
|
||||||
background-color: var(--nord4);
|
|
||||||
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<CardAdd bind:card_data={card_data} bind:image_preview_url={image_preview_url} bind:selected_image_file={selected_image_file} {short_name}></CardAdd>
|
|
||||||
|
|
||||||
<input class=temp bind:value={short_name} placeholder="Kurzname"/>
|
|
||||||
|
|
||||||
<SeasonSelect bind:ranges={seasonRanges} />
|
|
||||||
<button onclick={() => console.log(seasonRanges)}>PRINTOUT season</button>
|
|
||||||
|
|
||||||
<h2>Zutaten</h2>
|
|
||||||
<CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList>
|
|
||||||
<h2>Zubereitung</h2>
|
|
||||||
<CreateStepList bind:instructions={instructions} add_info={{}}></CreateStepList>
|
|
||||||
<input class=temp type="password" placeholder=Passwort bind:value={password}>
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Client-side image editing pipeline (no DOM / Svelte deps).
|
||||||
|
*
|
||||||
|
* The browser already ships a WebP encoder via `canvas.toBlob(cb, 'image/webp', q)`.
|
||||||
|
* Crop, scale-to-fit and the size readout are built on top of `<canvas>`; the
|
||||||
|
* encode is the only primitive we don't hand-roll. `sharp` cannot run here — it's
|
||||||
|
* a native Node binding — so all of this happens on the main thread.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CropRect = { x: number; y: number; w: number; h: number };
|
||||||
|
export type Size = { w: number; h: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a File into an ImageBitmap, honouring EXIF orientation so that
|
||||||
|
* sideways phone photos render upright.
|
||||||
|
*/
|
||||||
|
export async function loadBitmap(file: File): Promise<ImageBitmap> {
|
||||||
|
try {
|
||||||
|
return await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||||
|
} catch {
|
||||||
|
// Older Safari ignores the options bag — fall back to the plain call.
|
||||||
|
return await createImageBitmap(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale `w`×`h` to fit inside a `max`×`max` box, preserving aspect ratio.
|
||||||
|
* Never upscales (a smaller source is returned untouched). `max <= 0` means
|
||||||
|
* "no limit" (Original).
|
||||||
|
*/
|
||||||
|
export function fitWithin(w: number, h: number, max: number): Size {
|
||||||
|
if (max <= 0 || (w <= max && h <= max)) {
|
||||||
|
return { w: Math.round(w), h: Math.round(h) };
|
||||||
|
}
|
||||||
|
const scale = Math.min(max / w, max / h);
|
||||||
|
return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crop `bitmap` to `crop` (source pixels), scale the result to fit `maxRes`,
|
||||||
|
* and encode as WebP at `quality` (1–100). Returns the encoded Blob; read
|
||||||
|
* `.size` for the final byte count.
|
||||||
|
*/
|
||||||
|
export async function renderToBlob(
|
||||||
|
bitmap: ImageBitmap,
|
||||||
|
crop: CropRect,
|
||||||
|
maxRes: number,
|
||||||
|
quality: number
|
||||||
|
): Promise<Blob> {
|
||||||
|
const out = fitWithin(crop.w, crop.h, maxRes);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = out.w;
|
||||||
|
canvas.height = out.h;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('2D canvas context unavailable');
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(bitmap, crop.x, crop.y, crop.w, crop.h, 0, 0, out.w, out.h);
|
||||||
|
|
||||||
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => (blob ? resolve(blob) : reject(new Error('WebP encoding failed'))),
|
||||||
|
'image/webp',
|
||||||
|
Math.min(1, Math.max(0.01, quality / 100))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap an encoded Blob as a File the form can upload. */
|
||||||
|
export function blobToFile(blob: Blob, shortName: string): File {
|
||||||
|
const base = (shortName || 'image').trim() || 'image';
|
||||||
|
return new File([blob], `${base}.webp`, { type: 'image/webp' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable byte size, e.g. "412 KB" / "1.3 MB". */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
@@ -2,15 +2,14 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import Check from '$lib/assets/icons/Check.svelte';
|
import SaveFab from '$lib/components/SaveFab.svelte';
|
||||||
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
|
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
|
||||||
import TranslationApproval from '$lib/components/recipes/TranslationApproval.svelte';
|
import TranslationApproval from '$lib/components/recipes/TranslationApproval.svelte';
|
||||||
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
|
import EditTitleImgParallax from '$lib/components/recipes/EditTitleImgParallax.svelte';
|
||||||
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
|
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
|
||||||
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
|
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
|
||||||
import { toast } from '$lib/js/toast.svelte';
|
import { toast } from '$lib/js/toast.svelte';
|
||||||
import Toggle from '$lib/components/Toggle.svelte';
|
import Toggle from '$lib/components/Toggle.svelte';
|
||||||
import '$lib/css/action_button.css';
|
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
@@ -87,20 +86,30 @@
|
|||||||
defaultForm,
|
defaultForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show translation workflow before submission
|
function validate(): boolean {
|
||||||
function prepareSubmit() {
|
|
||||||
// Client-side validation
|
|
||||||
if (!short_name.trim()) {
|
if (!short_name.trim()) {
|
||||||
toast.error('Bitte geben Sie einen Kurznamen ein');
|
toast.error('Bitte geben Sie einen Kurznamen ein');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (!card_data.name) {
|
if (!card_data.name) {
|
||||||
toast.error('Bitte geben Sie einen Namen ein');
|
toast.error('Bitte geben Sie einen Namen ein');
|
||||||
return;
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create directly without an English translation (mirrors /edit's SaveFab).
|
||||||
|
async function saveRecipe() {
|
||||||
|
if (!validate()) return;
|
||||||
|
translationData = null;
|
||||||
|
await tick();
|
||||||
|
formElement?.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the optional translation workflow before submission.
|
||||||
|
function openTranslation() {
|
||||||
|
if (!validate()) return;
|
||||||
showTranslationWorkflow = true;
|
showTranslationWorkflow = true;
|
||||||
// Scroll to translation section
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
|
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -147,20 +156,112 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
input {
|
h3 {
|
||||||
display: block;
|
text-align: center;
|
||||||
border: unset;
|
font-size: 1.15rem;
|
||||||
margin: 1rem auto;
|
letter-spacing: 0.02em;
|
||||||
padding: 0.5em 1em;
|
margin-block: 1.25rem 0.75rem;
|
||||||
border-radius: var(--radius-pill);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--nord4);
|
}
|
||||||
|
|
||||||
|
/* ===== Below-hero content wrapper: full-width backdrop hides the sticky hero ===== */
|
||||||
|
.below-hero {
|
||||||
|
--bg-color: var(--color-bg-primary);
|
||||||
|
position: relative;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
.below-hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100vw;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Title-card extras (inside hero card) ===== */
|
||||||
|
.section-label {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-block: 1.25rem 0.5rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.season-wrapper {
|
||||||
|
margin-block: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
.preamble {
|
||||||
|
margin: 0.5rem 0 0.25rem;
|
||||||
|
padding: 1em 1.25em;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
min-height: 3em;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
|
}
|
||||||
|
.preamble:focus,
|
||||||
|
.preamble:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.preamble:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Meta row under the hero: URL + base-recipe toggle ===== */
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem 2rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-block: 0.5rem 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.url-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.url-field input {
|
||||||
|
display: block;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.55em 1.1em;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
min-width: 16rem;
|
||||||
transition: var(--transition-fast);
|
transition: var(--transition-fast);
|
||||||
}
|
}
|
||||||
input:hover,
|
.url-field input:hover,
|
||||||
input:focus-visible {
|
.url-field input:focus-visible {
|
||||||
scale: 1.05 1.05;
|
border-color: var(--color-primary);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
.toggle-field {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Ingredients + Instructions two-col ===== */
|
||||||
.list_wrapper {
|
.list_wrapper {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -168,116 +269,205 @@ input:focus-visible {
|
|||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
margin-block: 2.5rem;
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 700px) {
|
@media screen and (max-width: 700px) {
|
||||||
.list_wrapper {
|
.list_wrapper {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h1 {
|
|
||||||
text-align: center;
|
/* ===== Addendum ===== */
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.title_container {
|
|
||||||
max-width: 1000px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
position: relative;
|
|
||||||
width: min(800px, 80vw);
|
|
||||||
margin-block: 2rem;
|
|
||||||
margin-inline: auto;
|
|
||||||
background-color: var(--nord6);
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
}
|
|
||||||
.title p {
|
|
||||||
border: 2px solid var(--nord1);
|
|
||||||
border-radius: 10000px;
|
|
||||||
padding: 0.5em 1em;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.title p:hover,
|
|
||||||
.title p:focus-within {
|
|
||||||
scale: 1.02 1.02;
|
|
||||||
}
|
|
||||||
.addendum {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
max-width: 90%;
|
|
||||||
margin-inline: auto;
|
|
||||||
border: 2px solid var(--nord1);
|
|
||||||
border-radius: 45px;
|
|
||||||
padding: 1em 1em;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
.addendum:hover,
|
|
||||||
.addendum:focus-within {
|
|
||||||
scale: 1.02 1.02;
|
|
||||||
}
|
|
||||||
.addendum_wrapper {
|
.addendum_wrapper {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
|
margin: 2.5rem auto;
|
||||||
|
}
|
||||||
|
.addendum {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
max-width: min(720px, 100%);
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
|
padding: 1em 1.25em;
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
min-height: 3em;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 200ms ease;
|
||||||
}
|
}
|
||||||
h3 {
|
.addendum:hover,
|
||||||
text-align: center;
|
.addendum:focus-visible {
|
||||||
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
button.action_button {
|
.addendum:empty::before {
|
||||||
animation: unset !important;
|
content: attr(data-placeholder);
|
||||||
font-size: 1.3rem;
|
color: var(--color-text-tertiary);
|
||||||
color: white;
|
font-style: italic;
|
||||||
}
|
|
||||||
.submit_buttons {
|
|
||||||
display: flex;
|
|
||||||
margin-inline: auto;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin-block: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
.submit_buttons p {
|
|
||||||
padding: 0;
|
|
||||||
padding-right: 0.5em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:global(:root:not([data-theme="light"])) .title {
|
|
||||||
background-color: var(--nord6-dark);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
:global(:root[data-theme="dark"]) .title {
|
|
||||||
background-color: var(--nord6-dark);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Form-size / Backform ===== */
|
||||||
.form-size-section {
|
.form-size-section {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 1rem auto;
|
margin: 2rem auto;
|
||||||
text-align: center;
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.form-size-controls {
|
.form-size-head {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.form-size-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.form-size-body {
|
||||||
|
padding: 0.25rem 1rem 1rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.form-shape-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.form-shape-row .shape-tile {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 0.5rem;
|
height: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
.form-shape-row .shape-tile:hover,
|
||||||
|
.form-shape-row .shape-tile:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-shape-row .shape-tile[aria-checked="true"] {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-tertiary));
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.form-shape-row .shape-tile svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
.form-size-inputs {
|
.form-size-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.form-size-inputs .input-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.form-size-inputs .input-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.form-size-inputs .input-box {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||||
}
|
}
|
||||||
.form-size-inputs input[type="number"] {
|
.form-size-inputs .input-box:focus-within {
|
||||||
width: 4em;
|
border-color: var(--color-primary);
|
||||||
display: inline;
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||||
margin: 0 0.3em;
|
|
||||||
}
|
}
|
||||||
|
.form-size-inputs .input-box input {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.55rem 2.25rem 0.55rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.form-size-inputs .input-box input::-webkit-outer-spin-button,
|
||||||
|
.form-size-inputs .input-box input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.form-size-inputs .input-box input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
.form-size-inputs .input-suffix {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
pointer-events: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.form-size-head { padding: 0.65rem 0.75rem; }
|
||||||
|
.form-size-body { padding: 0.25rem 0.75rem 0.85rem; }
|
||||||
|
.form-shape-row .shape-tile { height: 2rem; }
|
||||||
|
.form-shape-row .shape-tile svg { width: 1.1rem; height: 1.1rem; }
|
||||||
|
.form-size-inputs { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Translation trigger ===== */
|
||||||
|
.translation-section-trigger {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 2.5rem auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.section-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.section-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
.section-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.section-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
background: var(--nord11);
|
background: var(--red);
|
||||||
color: var(--nord6);
|
color: white;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-md);
|
||||||
margin: 1rem auto;
|
margin: 1rem auto;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -289,8 +479,6 @@ button.action_button {
|
|||||||
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
|
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1>Rezept erstellen</h1>
|
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
<strong>Fehler:</strong> {form.error}
|
<strong>Fehler:</strong> {form.error}
|
||||||
@@ -330,16 +518,6 @@ button.action_button {
|
|||||||
})} />
|
})} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CardAdd
|
|
||||||
bind:card_data
|
|
||||||
bind:image_preview_url
|
|
||||||
bind:selected_image_file
|
|
||||||
short_name={short_name}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h3>Kurzname (für URL):</h3>
|
|
||||||
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
|
|
||||||
|
|
||||||
<!-- Hidden inputs for card data -->
|
<!-- Hidden inputs for card data -->
|
||||||
<input type="hidden" name="name" value={card_data.name} />
|
<input type="hidden" name="name" value={card_data.name} />
|
||||||
<input type="hidden" name="description" value={card_data.description} />
|
<input type="hidden" name="description" value={card_data.description} />
|
||||||
@@ -348,67 +526,148 @@ button.action_button {
|
|||||||
<input type="hidden" name="portions" value={portions_local} />
|
<input type="hidden" name="portions" value={portions_local} />
|
||||||
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
|
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
|
||||||
<input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} />
|
<input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} />
|
||||||
|
<input type="hidden" name="preamble" value={preamble} />
|
||||||
|
|
||||||
<div style="text-align: center; margin: 1rem;">
|
<EditTitleImgParallax
|
||||||
<Toggle
|
bind:card_data
|
||||||
bind:checked={isBaseRecipe}
|
bind:image_preview_url
|
||||||
label="Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)"
|
bind:selected_image_file
|
||||||
/>
|
>
|
||||||
|
{#snippet titleExtras()}
|
||||||
|
<h2 class="section-label">Saison</h2>
|
||||||
|
<div class="season-wrapper">
|
||||||
|
<SeasonSelect bind:ranges={season_local} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">Einleitung</h2>
|
||||||
|
<p
|
||||||
|
class="preamble"
|
||||||
|
contenteditable="plaintext-only"
|
||||||
|
bind:innerText={preamble}
|
||||||
|
data-placeholder="Eine etwas längere Einleitung für dieses Rezept…"
|
||||||
|
aria-label="Einleitung"
|
||||||
|
></p>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="below-hero">
|
||||||
|
<div class="meta-row">
|
||||||
|
<label class="url-field">
|
||||||
|
<span>URL-Kurzname</span>
|
||||||
|
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
|
||||||
|
</label>
|
||||||
|
<div class="toggle-field">
|
||||||
|
<Toggle bind:checked={isBaseRecipe} label="Als Basisrezept markieren" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Default Form (Cake Pan) -->
|
|
||||||
<div class="form-size-section">
|
<div class="form-size-section">
|
||||||
<h3>Backform (Standard):</h3>
|
<div class="form-size-head">
|
||||||
<div class="form-size-controls">
|
<span class="form-size-title">Backform (Standard)</span>
|
||||||
<label>
|
|
||||||
<input type="radio" name="formShape" value="none" checked={!defaultForm} onchange={() => { defaultForm = null; }
|
|
||||||
} />
|
|
||||||
Keine
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="formShape" value="round" checked={defaultForm?.shape === 'round'} onchange={() => { defaultForm = { shape: 'round', diameter: defaultForm?.diameter || 26 }; }
|
|
||||||
} />
|
|
||||||
Rund
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="formShape" value="rectangular" checked={defaultForm?.shape === 'rectangular'} onchange={() => { defaultForm = { shape: 'rectangular', width: defaultForm?.width || 20, length: defaultForm?.length || 30 }; }
|
|
||||||
} />
|
|
||||||
Rechteckig
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="radio" name="formShape" value="gugelhupf" checked={defaultForm?.shape === 'gugelhupf'} onchange={() => { defaultForm = { shape: 'gugelhupf', diameter: defaultForm?.diameter || 24, innerDiameter: defaultForm?.innerDiameter || 8 }; }
|
|
||||||
} />
|
|
||||||
Gugelhupf
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-size-body">
|
||||||
|
<div class="form-shape-row" role="radiogroup" aria-label="Backform">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={!defaultForm}
|
||||||
|
aria-label="Keine"
|
||||||
|
title="Keine"
|
||||||
|
class="shape-tile"
|
||||||
|
onclick={() => { defaultForm = null; }}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="8.5"/>
|
||||||
|
<path d="m6 6 12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={defaultForm?.shape === 'round'}
|
||||||
|
aria-label="Rund"
|
||||||
|
title="Rund"
|
||||||
|
class="shape-tile"
|
||||||
|
onclick={() => { defaultForm = { shape: 'round', diameter: defaultForm?.diameter || 26 }; }}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="8.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={defaultForm?.shape === 'rectangular'}
|
||||||
|
aria-label="Rechteckig"
|
||||||
|
title="Rechteckig"
|
||||||
|
class="shape-tile"
|
||||||
|
onclick={() => { defaultForm = { shape: 'rectangular', width: defaultForm?.width || 20, length: defaultForm?.length || 30 }; }}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={defaultForm?.shape === 'gugelhupf'}
|
||||||
|
aria-label="Gugelhupf"
|
||||||
|
title="Gugelhupf"
|
||||||
|
class="shape-tile"
|
||||||
|
onclick={() => { defaultForm = { shape: 'gugelhupf', diameter: defaultForm?.diameter || 24, innerDiameter: defaultForm?.innerDiameter || 8 }; }}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="8.5"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if defaultForm?.shape === 'round'}
|
{#if defaultForm?.shape === 'round'}
|
||||||
<div class="form-size-inputs">
|
<div class="form-size-inputs">
|
||||||
<label>Durchmesser: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
|
<label class="input-wrap">
|
||||||
|
<span class="input-label">Durchmesser</span>
|
||||||
|
<span class="input-box">
|
||||||
|
<input type="number" min="1" step="1" bind:value={defaultForm.diameter} />
|
||||||
|
<span class="input-suffix">cm</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{:else if defaultForm?.shape === 'rectangular'}
|
{:else if defaultForm?.shape === 'rectangular'}
|
||||||
<div class="form-size-inputs">
|
<div class="form-size-inputs">
|
||||||
<label>Breite: <input type="number" min="1" step="1" bind:value={defaultForm.width} /> cm</label>
|
<label class="input-wrap">
|
||||||
<label>Länge: <input type="number" min="1" step="1" bind:value={defaultForm.length} /> cm</label>
|
<span class="input-label">Breite</span>
|
||||||
|
<span class="input-box">
|
||||||
|
<input type="number" min="1" step="1" bind:value={defaultForm.width} />
|
||||||
|
<span class="input-suffix">cm</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="input-wrap">
|
||||||
|
<span class="input-label">Länge</span>
|
||||||
|
<span class="input-box">
|
||||||
|
<input type="number" min="1" step="1" bind:value={defaultForm.length} />
|
||||||
|
<span class="input-suffix">cm</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{:else if defaultForm?.shape === 'gugelhupf'}
|
{:else if defaultForm?.shape === 'gugelhupf'}
|
||||||
<div class="form-size-inputs">
|
<div class="form-size-inputs">
|
||||||
<label>Aussen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
|
<label class="input-wrap">
|
||||||
<label>Innen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.innerDiameter} /> cm</label>
|
<span class="input-label">Aussen-Ø</span>
|
||||||
|
<span class="input-box">
|
||||||
|
<input type="number" min="1" step="1" bind:value={defaultForm.diameter} />
|
||||||
|
<span class="input-suffix">cm</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="input-wrap">
|
||||||
|
<span class="input-label">Innen-Ø</span>
|
||||||
|
<span class="input-box">
|
||||||
|
<input type="number" min="1" step="1" bind:value={defaultForm.innerDiameter} />
|
||||||
|
<span class="input-suffix">cm</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title_container">
|
|
||||||
<div class="title">
|
|
||||||
<h4>Eine etwas längere Beschreibung:</h4>
|
|
||||||
<p bind:innerText={preamble} contenteditable></p>
|
|
||||||
<input type="hidden" name="preamble" value={preamble} />
|
|
||||||
|
|
||||||
<div class="tags">
|
|
||||||
<h4>Saison:</h4>
|
|
||||||
<SeasonSelect bind:ranges={season_local} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="list_wrapper">
|
<div class="list_wrapper">
|
||||||
@@ -421,26 +680,33 @@ button.action_button {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="addendum_wrapper">
|
<div class="addendum_wrapper">
|
||||||
<h3>Nachtrag:</h3>
|
<h3>Nachtrag</h3>
|
||||||
<div class="addendum" bind:innerText={addendum} contenteditable></div>
|
<div
|
||||||
|
class="addendum"
|
||||||
|
contenteditable="plaintext-only"
|
||||||
|
bind:innerText={addendum}
|
||||||
|
data-placeholder="Optionaler Nachtrag…"
|
||||||
|
aria-label="Nachtrag"
|
||||||
|
></div>
|
||||||
<input type="hidden" name="addendum" value={addendum} />
|
<input type="hidden" name="addendum" value={addendum} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !showTranslationWorkflow}
|
{#if !showTranslationWorkflow}
|
||||||
<div class="submit_buttons">
|
<div class="translation-section-trigger">
|
||||||
<button
|
<h3>Übersetzung</h3>
|
||||||
type="button"
|
<div class="section-actions">
|
||||||
class="action_button"
|
<button type="button" class="section-btn" onclick={openTranslation} disabled={submitting}>
|
||||||
onclick={prepareSubmit}
|
Übersetzen & erstellen
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
<p>Weiter zur Übersetzung</p>
|
|
||||||
<Check fill="white" width="2rem" height="2rem" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
</EditTitleImgParallax>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<SaveFab type="button" onclick={saveRecipe} disabled={submitting} label="Rezept erstellen" />
|
||||||
|
|
||||||
{#if showTranslationWorkflow}
|
{#if showTranslationWorkflow}
|
||||||
<div id="translation-section">
|
<div id="translation-section">
|
||||||
<TranslationApproval
|
<TranslationApproval
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
||||||
import { validateImageFile } from '$utils/imageValidation';
|
import { validateImageFile } from '$utils/imageValidation';
|
||||||
@@ -132,21 +133,27 @@ export async function processAndSaveRecipeImage(
|
|||||||
unhashed: unhashedFilename
|
unhashed: unhashedFilename
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process image with Sharp - convert to WebP format
|
// Full size: the client photo editor already crops, scales and encodes WebP at
|
||||||
// Save full size - both hashed and unhashed versions
|
// the user's chosen quality. Store that byte-for-byte so the on-disk file matches
|
||||||
console.log('[ImageProcessing] Converting to WebP and generating full size...');
|
// the size the user saw in the editor — re-encoding through sharp would silently
|
||||||
const fullBuffer = await sharp(buffer)
|
// re-compress and discard their quality/size choice.
|
||||||
.toFormat('webp')
|
// Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before.
|
||||||
.webp({ quality: 90 }) // High quality for full size
|
|
||||||
.toBuffer();
|
|
||||||
console.log('[ImageProcessing] Full size buffer created, size:', fullBuffer.length, 'bytes');
|
|
||||||
|
|
||||||
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename);
|
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename);
|
||||||
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename);
|
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename);
|
||||||
|
|
||||||
|
let fullBuffer: Buffer;
|
||||||
|
if (file.type === 'image/webp') {
|
||||||
|
console.log('[ImageProcessing] Client WebP detected — storing full size as-is (passthrough)');
|
||||||
|
fullBuffer = buffer;
|
||||||
|
} else {
|
||||||
|
console.log('[ImageProcessing] Non-WebP upload — re-encoding full size to WebP q90...');
|
||||||
|
fullBuffer = await sharp(buffer).toFormat('webp').webp({ quality: 90 }).toBuffer();
|
||||||
|
}
|
||||||
|
console.log('[ImageProcessing] Full size buffer ready, size:', fullBuffer.length, 'bytes');
|
||||||
console.log('[ImageProcessing] Saving full size to:', { fullHashedPath, fullUnhashedPath });
|
console.log('[ImageProcessing] Saving full size to:', { fullHashedPath, fullUnhashedPath });
|
||||||
|
|
||||||
await sharp(fullBuffer).toFile(fullHashedPath);
|
await writeFile(fullHashedPath, fullBuffer);
|
||||||
await sharp(fullBuffer).toFile(fullUnhashedPath);
|
await writeFile(fullUnhashedPath, fullBuffer);
|
||||||
console.log('[ImageProcessing] Full size images saved');
|
console.log('[ImageProcessing] Full size images saved');
|
||||||
|
|
||||||
// Save thumbnail (800px width) - both hashed and unhashed versions
|
// Save thumbnail (800px width) - both hashed and unhashed versions
|
||||||
|
|||||||
Reference in New Issue
Block a user