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:
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user