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)
|
||||
- `FavoriteButton.svelte` - Toggle favorite
|
||||
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
|
||||
- `CardAdd.svelte` - Add recipe card placeholder
|
||||
- `FormSection.svelte` - Styled form section wrapper
|
||||
- `Header.svelte` - Page header
|
||||
- `UserHeader.svelte` - User-specific header
|
||||
@@ -190,7 +189,6 @@ Generated: 2025-11-18
|
||||
|
||||
#### Recipe-Specific Components
|
||||
- `Recipes.svelte` - Recipe list display
|
||||
- `RecipeEditor.svelte` - Recipe editing form
|
||||
- `RecipeNote.svelte` - Recipe notes display
|
||||
- `EditRecipe.svelte` - Edit recipe modal
|
||||
- `EditRecipeNote.svelte` - Edit recipe notes
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.94.1",
|
||||
"version": "1.95.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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">
|
||||
import Cross from '$lib/assets/icons/Cross.svelte';
|
||||
import ImageEditor from '$lib/components/recipes/ImageEditor.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
|
||||
@@ -53,9 +54,34 @@
|
||||
input.value = '';
|
||||
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);
|
||||
image_preview_url = URL.createObjectURL(file);
|
||||
selected_image_file = file;
|
||||
image_preview_url = url;
|
||||
closeEditor();
|
||||
}
|
||||
|
||||
function editCurrentImage() {
|
||||
if (selected_image_file) openEditor(selected_image_file);
|
||||
}
|
||||
|
||||
function clearSelectedImage() {
|
||||
@@ -129,15 +155,30 @@
|
||||
</div>
|
||||
</button>
|
||||
{#if selected_image_file}
|
||||
<button
|
||||
type="button"
|
||||
class="clear-img"
|
||||
onclick={clearSelectedImage}
|
||||
title="Auswahl verwerfen"
|
||||
aria-label="Auswahl verwerfen"
|
||||
>
|
||||
<Cross fill="white" width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
<div class="img-controls">
|
||||
<button
|
||||
type="button"
|
||||
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}
|
||||
title="Auswahl verwerfen"
|
||||
aria-label="Auswahl verwerfen"
|
||||
>
|
||||
<Cross fill="white" width="1.15rem" height="1.15rem" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
@@ -215,6 +256,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if editorOpen && editorFile}
|
||||
<ImageEditor file={editorFile} onApply={handleEditorApply} onCancel={closeEditor} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.section {
|
||||
--scale: 0.3;
|
||||
@@ -312,10 +357,18 @@
|
||||
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;
|
||||
top: calc(1rem + env(safe-area-inset-top, 0px));
|
||||
top: calc(max(12px, env(safe-area-inset-top, 0px) + 4px) + 3rem + 1rem);
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
z-index: 5;
|
||||
}
|
||||
.img-btn {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: none;
|
||||
width: 2.5rem;
|
||||
@@ -324,17 +377,26 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
background 150ms ease;
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.clear-img:hover,
|
||||
.clear-img:focus-visible {
|
||||
background: var(--red);
|
||||
.img-btn svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
fill: white;
|
||||
}
|
||||
.img-btn:hover,
|
||||
.img-btn:focus-visible {
|
||||
background: var(--color-primary);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.img-btn.danger:hover,
|
||||
.img-btn.danger:focus-visible {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
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 { tick } from 'svelte';
|
||||
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 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 CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import '$lib/css/action_button.css';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
@@ -87,20 +86,30 @@
|
||||
defaultForm,
|
||||
});
|
||||
|
||||
// Show translation workflow before submission
|
||||
function prepareSubmit() {
|
||||
// Client-side validation
|
||||
function validate(): boolean {
|
||||
if (!short_name.trim()) {
|
||||
toast.error('Bitte geben Sie einen Kurznamen ein');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!card_data.name) {
|
||||
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;
|
||||
// Scroll to translation section
|
||||
setTimeout(() => {
|
||||
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 100);
|
||||
@@ -147,141 +156,322 @@
|
||||
</script>
|
||||
|
||||
<style>
|
||||
input {
|
||||
display: block;
|
||||
border: unset;
|
||||
margin: 1rem auto;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: var(--nord4);
|
||||
font-size: 1.1rem;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
input:hover,
|
||||
input:focus-visible {
|
||||
scale: 1.05 1.05;
|
||||
}
|
||||
.list_wrapper {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 1000px;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
@media screen and (max-width: 700px) {
|
||||
.list_wrapper {
|
||||
h3 {
|
||||
text-align: center;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.02em;
|
||||
margin-block: 1.25rem 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* ===== 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-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;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
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 {
|
||||
max-width: 1000px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
h3 {
|
||||
text-align: center;
|
||||
}
|
||||
button.action_button {
|
||||
animation: unset !important;
|
||||
font-size: 1.3rem;
|
||||
color: white;
|
||||
}
|
||||
.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);
|
||||
.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);
|
||||
}
|
||||
.url-field input:hover,
|
||||
.url-field input:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
.toggle-field {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* ===== Ingredients + Instructions two-col ===== */
|
||||
.list_wrapper {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 1000px;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
margin-block: 2.5rem;
|
||||
}
|
||||
@media screen and (max-width: 700px) {
|
||||
.list_wrapper {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Addendum ===== */
|
||||
.addendum_wrapper {
|
||||
max-width: 1000px;
|
||||
margin: 2.5rem auto;
|
||||
}
|
||||
.addendum {
|
||||
font-size: 1.05rem;
|
||||
max-width: min(720px, 100%);
|
||||
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;
|
||||
}
|
||||
.addendum:hover,
|
||||
.addendum:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.addendum:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--color-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ===== Form-size / Backform ===== */
|
||||
.form-size-section {
|
||||
max-width: 600px;
|
||||
margin: 2rem auto;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.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;
|
||||
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;
|
||||
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 {
|
||||
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;
|
||||
align-items: center;
|
||||
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-box:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
.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 {
|
||||
background: var(--red);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
margin: 1rem auto;
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .title {
|
||||
background-color: var(--nord6-dark);
|
||||
}
|
||||
.form-size-section {
|
||||
max-width: 600px;
|
||||
margin: 1rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
.form-size-controls {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.form-size-inputs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.form-size-inputs input[type="number"] {
|
||||
width: 4em;
|
||||
display: inline;
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
.error-message {
|
||||
background: var(--nord11);
|
||||
color: var(--nord6);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem auto;
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
@@ -289,8 +479,6 @@ button.action_button {
|
||||
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
|
||||
</svelte:head>
|
||||
|
||||
<h1>Rezept erstellen</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="error-message">
|
||||
<strong>Fehler:</strong> {form.error}
|
||||
@@ -330,16 +518,6 @@ button.action_button {
|
||||
})} />
|
||||
{/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 -->
|
||||
<input type="hidden" name="name" value={card_data.name} />
|
||||
<input type="hidden" name="description" value={card_data.description} />
|
||||
@@ -348,99 +526,187 @@ button.action_button {
|
||||
<input type="hidden" name="portions" value={portions_local} />
|
||||
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
|
||||
<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;">
|
||||
<Toggle
|
||||
bind:checked={isBaseRecipe}
|
||||
label="Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Default Form (Cake Pan) -->
|
||||
<div class="form-size-section">
|
||||
<h3>Backform (Standard):</h3>
|
||||
<div class="form-size-controls">
|
||||
<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>
|
||||
{#if defaultForm?.shape === 'round'}
|
||||
<div class="form-size-inputs">
|
||||
<label>Durchmesser: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
|
||||
</div>
|
||||
{:else if defaultForm?.shape === 'rectangular'}
|
||||
<div class="form-size-inputs">
|
||||
<label>Breite: <input type="number" min="1" step="1" bind:value={defaultForm.width} /> cm</label>
|
||||
<label>Länge: <input type="number" min="1" step="1" bind:value={defaultForm.length} /> cm</label>
|
||||
</div>
|
||||
{:else if defaultForm?.shape === 'gugelhupf'}
|
||||
<div class="form-size-inputs">
|
||||
<label>Aussen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.diameter} /> cm</label>
|
||||
<label>Innen-Ø: <input type="number" min="1" step="1" bind:value={defaultForm.innerDiameter} /> cm</label>
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
<EditTitleImgParallax
|
||||
bind:card_data
|
||||
bind:image_preview_url
|
||||
bind:selected_image_file
|
||||
>
|
||||
{#snippet titleExtras()}
|
||||
<h2 class="section-label">Saison</h2>
|
||||
<div class="season-wrapper">
|
||||
<SeasonSelect bind:ranges={season_local} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list_wrapper">
|
||||
<div>
|
||||
<CreateIngredientList bind:ingredients />
|
||||
</div>
|
||||
<div>
|
||||
<CreateStepList bind:instructions bind:add_info />
|
||||
</div>
|
||||
</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="addendum_wrapper">
|
||||
<h3>Nachtrag:</h3>
|
||||
<div class="addendum" bind:innerText={addendum} contenteditable></div>
|
||||
<input type="hidden" name="addendum" value={addendum} />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{#if !showTranslationWorkflow}
|
||||
<div class="submit_buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="action_button"
|
||||
onclick={prepareSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
<p>Weiter zur Übersetzung</p>
|
||||
<Check fill="white" width="2rem" height="2rem" />
|
||||
</button>
|
||||
<div class="form-size-section">
|
||||
<div class="form-size-head">
|
||||
<span class="form-size-title">Backform (Standard)</span>
|
||||
</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'}
|
||||
<div class="form-size-inputs">
|
||||
<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>
|
||||
{:else if defaultForm?.shape === 'rectangular'}
|
||||
<div class="form-size-inputs">
|
||||
<label class="input-wrap">
|
||||
<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>
|
||||
{:else if defaultForm?.shape === 'gugelhupf'}
|
||||
<div class="form-size-inputs">
|
||||
<label class="input-wrap">
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list_wrapper">
|
||||
<div>
|
||||
<CreateIngredientList bind:ingredients />
|
||||
</div>
|
||||
<div>
|
||||
<CreateStepList bind:instructions bind:add_info />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addendum_wrapper">
|
||||
<h3>Nachtrag</h3>
|
||||
<div
|
||||
class="addendum"
|
||||
contenteditable="plaintext-only"
|
||||
bind:innerText={addendum}
|
||||
data-placeholder="Optionaler Nachtrag…"
|
||||
aria-label="Nachtrag"
|
||||
></div>
|
||||
<input type="hidden" name="addendum" value={addendum} />
|
||||
</div>
|
||||
|
||||
{#if !showTranslationWorkflow}
|
||||
<div class="translation-section-trigger">
|
||||
<h3>Übersetzung</h3>
|
||||
<div class="section-actions">
|
||||
<button type="button" class="section-btn" onclick={openTranslation} disabled={submitting}>
|
||||
Übersetzen & erstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</EditTitleImgParallax>
|
||||
</form>
|
||||
|
||||
<SaveFab type="button" onclick={saveRecipe} disabled={submitting} label="Rezept erstellen" />
|
||||
|
||||
{#if showTranslationWorkflow}
|
||||
<div id="translation-section">
|
||||
<TranslationApproval
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
||||
import { validateImageFile } from '$utils/imageValidation';
|
||||
@@ -132,21 +133,27 @@ export async function processAndSaveRecipeImage(
|
||||
unhashed: unhashedFilename
|
||||
});
|
||||
|
||||
// Process image with Sharp - convert to WebP format
|
||||
// Save full size - both hashed and unhashed versions
|
||||
console.log('[ImageProcessing] Converting to WebP and generating full size...');
|
||||
const fullBuffer = await sharp(buffer)
|
||||
.toFormat('webp')
|
||||
.webp({ quality: 90 }) // High quality for full size
|
||||
.toBuffer();
|
||||
console.log('[ImageProcessing] Full size buffer created, size:', fullBuffer.length, 'bytes');
|
||||
|
||||
// Full size: the client photo editor already crops, scales and encodes WebP at
|
||||
// the user's chosen quality. Store that byte-for-byte so the on-disk file matches
|
||||
// the size the user saw in the editor — re-encoding through sharp would silently
|
||||
// re-compress and discard their quality/size choice.
|
||||
// Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before.
|
||||
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename);
|
||||
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 });
|
||||
|
||||
await sharp(fullBuffer).toFile(fullHashedPath);
|
||||
await sharp(fullBuffer).toFile(fullUnhashedPath);
|
||||
await writeFile(fullHashedPath, fullBuffer);
|
||||
await writeFile(fullUnhashedPath, fullBuffer);
|
||||
console.log('[ImageProcessing] Full size images saved');
|
||||
|
||||
// Save thumbnail (800px width) - both hashed and unhashed versions
|
||||
|
||||
Reference in New Issue
Block a user