6 Commits

Author SHA1 Message Date
Alexander 467f9a4e71 feat(tasks): vinyl sticker album + fridge-calendar rewards redesign
CI / update (push) Has been cancelled
Replace the pokedex grid on /tasks/rewards with a scrapbook "sticker album":
category pages on warm paper, die-cut glossy vinyl stickers (debossed
silhouettes for missing ones), rarity-scaled holo shine/foil/glow, and a
large sticker-themed detail popup on click. Pages sort by rarity (rarest
category + sticker first; "Allerlei" catch-all last); each category has an
info popover explaining how its stickers drop, with the /tasks tag icons.

Restyle the calendar as a cozy fridge wall-calendar (paper, washi tape,
Fredoka month, cats stuck on dates as tilted die-cut stickers, weekend
tint). Shows only the current user by default; tap a name in the monthly
tally to fold in the other household member (per-person colour dots).

Export ALWAYS_CATEGORIES + getTagsForCategory from stickers util.
2026-06-01 23:42:47 +02:00
Alexander 8bd794bccb perf(build): replace adapter precompress with a parallel, filtered step
adapter-node's `precompress: true` brotli-q11 + gzips every file in
build/client single-threaded — ~150 MB including 91 MB of already-
compressed media (zero gain) and tens of MB of server-only data — adding
minutes to the build. The worst offenders were ML embedding JSONs
(nutrition 35 MB + bls 20 MB + shopping ~4 MB) that are `?url`-imported by
$lib/server/{nutritionMatcher,shoppingCategorizer}.ts and read server-side
via SvelteKit's read(); no browser ever fetches them, so compressing them
is pure waste.

- svelte.config.js: precompress: false.
- scripts/precompress.ts: postbuild step that only compresses text asset
  types, skips binaries and server-only data (bible TSVs by name, embedding
  JSONs by hashed-name pattern), tunes brotli quality down for large files,
  runs gzip+brotli in parallel, and never writes a larger-than-original or
  duplicate sibling.
- package.json: run precompress after build-error-page with
  UV_THREADPOOL_SIZE=12 so the async compression actually parallelizes.
- Delete static/allioli.json: 20 MB, unreferenced anywhere in the repo.
2026-06-01 16:11:52 +02:00
Alexander f52d6b4d4b perf(tasks): show completion sticker instantly
Roll the sticker client-side and render the popup immediately on completion
instead of waiting for the POST roundtrip + DB writes to return it. Preload
the sticker image so the cat is decoded before the bounce-in finishes. The
chosen stickerId is sent to the server, which persists it (falling back to a
server-side roll if missing/invalid).
2026-06-01 16:05:54 +02:00
Alexander 9b5cfe5e49 fix(recipes): build deploy against .env_prod; harden image save
Recipe image upload failed in prod with ENOENT writing the full image.
Root cause: the deploy built against the dev .env, whose relative
IMAGE_DIR="./imgs/" resolves under the service's dist/ working dir
instead of the real served image directory — and `$env/static/private`
is inlined at build time, so dev values shipped to prod.

- deploy.sh: source .env_prod (overridable via PROD_ENV) into the env
  before `pnpm build`, so prod values win over .env for the whole build
  lifecycle; abort if it's missing rather than ship a dev-env build.
- .gitignore: ignore .env_* so .env_prod (prod secrets) isn't committed
  (the existing .env.* dot pattern didn't match the underscore form).
- imageProcessing: mkdir -p the full/thumb dirs before writing. The
  WebP passthrough writes the full image with fs.writeFile, which (unlike
  sharp's toFile) does not create parent dirs.
- recipeFormHelpers: add serializableFormValues() and use it in the add/
  edit actions' fail() returns. Returning raw formData (now containing the
  recipe_image File) crashed the action response with a non-POJO devalue
  error, masking the real failure with an opaque 500.
2026-05-31 13:49:04 +02:00
Alexander 9fe9d95e36 feat(hikes): unify below-map view transition into one sliding panel
The detail-page enter/exit transition previously slid only the metric
tiles up from the bottom — the wrapper had no background, so its
snapshot was transparent and no containing panel moved. The photo strip
also animated separately, sliding in from the right.

Wrap everything below the hero map (stage nav, photo strip, metrics,
tags, elevation, scroll area, footer) in one `.below-map` element with
`view-transition-name: hike-below-map` and an opaque background, so the
whole sheet — background included — slides up on enter and down on exit
as a single panel. Drop the obsolete hike-strip right-slide rules and
keyframes; rename hike-below-strip → hike-below-map.
2026-05-31 13:29:15 +02:00
Alexander cd7912fa8f feat(recipes): client-side image editor + modernize /add to match /edit
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.
2026-05-30 15:54:28 +02:00
27 changed files with 2576 additions and 417610 deletions
+1
View File
@@ -7,6 +7,7 @@ node_modules
/package
.env
.env.*
.env_*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
-2
View File
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.94.1",
"version": "1.96.0",
"private": true,
"type": "module",
"scripts": {
@@ -8,7 +8,7 @@
"dev": "vite dev",
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts && pnpm exec vite-node scripts/build-private-images.ts",
"build": "vite build",
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts && UV_THREADPOOL_SIZE=12 pnpm exec vite-node scripts/precompress.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+19 -1
View File
@@ -51,7 +51,25 @@ echo " node $local_node (match)"
echo ":: Installing deps (frozen lockfile)"
pnpm install --frozen-lockfile
echo ":: Building"
# Build against production env, NOT the dev .env. SvelteKit's
# `$env/static/private` (IMAGE_DIR, DB creds, …) is inlined at BUILD time, so a
# build that picks up the dev .env ships dev values to prod — e.g. the relative
# IMAGE_DIR="./imgs/" that resolves under the service's dist/ cwd instead of the
# real served image dir. We export .env_prod into the environment; real env vars
# take precedence over .env files in Vite/SvelteKit's env loading, so this wins
# for the whole `pnpm build` lifecycle (prebuild vite-node scripts + build).
PROD_ENV="${PROD_ENV:-.env_prod}"
if [[ ! -f "$PROD_ENV" ]]; then
echo "!! $PROD_ENV not found in $(pwd) — refusing to build with the dev .env."
echo " Create $PROD_ENV with production values (IMAGE_DIR must be an"
echo " ABSOLUTE path to the served recipe-image dir, DB creds, etc.)."
exit 1
fi
echo ":: Building (env from $PROD_ENV)"
set -a
# shellcheck source=/dev/null
source "$PROD_ENV"
set +a
pnpm build
if [[ ! -d build ]]; then
+174
View File
@@ -0,0 +1,174 @@
/**
* Postbuild: precompress static build output for nginx `gzip_static` /
* `brotli_static`.
*
* Replaces adapter-node's `precompress: true`, which brotli-q11 + gzips EVERY
* file in build/client single-threaded — including ~90 MB of already-compressed
* jpg/mp4/png/webp/woff2 (zero gain) and 20 MB+ text blobs at q11 (~30 s each).
*
* This version instead:
* - only touches compressible text types (skips binaries entirely),
* - tunes brotli quality down for large files (q11 is wildly slow past a few MB
* for marginal ratio gains over q10/q9),
* - runs gzip + brotli concurrently across the libuv threadpool,
* - skips files that already have a .br/.gz sibling (e.g. the error pages the
* build-error-page step emits), so it's idempotent.
*
* Run: pnpm exec vite-node scripts/precompress.ts
*/
// The async gzip/brotli calls run on libuv's threadpool. Its size must be set
// before the pool is first used — by the time this module runs under vite-node
// the pool is already up, so postbuild sets UV_THREADPOOL_SIZE on the command
// line (the authoritative knob). This line is just a fallback default for
// direct `vite-node scripts/precompress.ts` runs and won't override an
// already-set value.
import os from 'node:os';
const CORES = Math.max(1, os.cpus().length);
process.env.UV_THREADPOOL_SIZE ||= String(Math.min(CORES, 12));
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
import { join, resolve, dirname, extname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzip, brotliCompress, constants as zlib } from 'node:zlib';
import { promisify } from 'node:util';
const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const TARGET_DIRS = ['build/client', 'build/prerendered'];
// Only these extensions are worth compressing; everything else (images, video,
// fonts, archives) is already compressed and skipped.
const COMPRESSIBLE = new Set([
'.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.json', '.map',
'.svg', '.xml', '.txt', '.tsv', '.csv', '.wasm', '.webmanifest', '.ico'
]);
// Server-side-only data that nonetheless lands in build/client and is read back
// from disk server-side (never delivered to a browser). A .br/.gz sibling for
// these is dead weight nginx never serves — and they're the largest, slowest
// files in the tree, so skipping them is where almost all the time goes. They
// must still exist UNCOMPRESSED for the server reads, so we skip rather than
// remove them. Two kinds:
// - bible TSVs: read via src/lib/server/staticAsset.ts → resolveStaticAsset
// - ML embedding JSONs: `?url`-imported by $lib/server/{nutritionMatcher,
// shoppingCategorizer}.ts and read via SvelteKit's read(); emitted into
// _app/immutable/assets/ with a content hash (…Embeddings.<hash>.json).
const SERVER_ONLY_NAMES = new Set(['allioli.tsv', 'drb.tsv']);
const SERVER_ONLY_RE = /embeddings\.[^/]*\.json$/i;
function isServerOnly(file: string): boolean {
const base = basename(file);
return SERVER_ONLY_NAMES.has(base) || SERVER_ONLY_RE.test(base);
}
// Don't bother compressing tiny files — overhead/headers outweigh the savings.
const MIN_BYTES = 1024;
/** Pick a brotli quality that balances ratio against time for large files. */
function brotliQuality(size: number): number {
if (size > 4 * 1024 * 1024) return 9; // >4 MB: q9 (q11 would take 30 s+)
if (size > 1024 * 1024) return 10; // 14 MB
return 11; // small files: max ratio, still fast
}
async function* walk(dir: string): AsyncGenerator<string> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return; // dir doesn't exist (e.g. no prerendered output) — skip
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (entry.isFile()) yield full;
}
}
async function collect(): Promise<string[]> {
const files: string[] = [];
for (const rel of TARGET_DIRS) {
for await (const f of walk(join(ROOT, rel))) {
const ext = extname(f).toLowerCase();
if (!COMPRESSIBLE.has(ext)) continue;
if (f.endsWith('.gz') || f.endsWith('.br')) continue;
if (isServerOnly(f)) continue;
files.push(f);
}
}
return files;
}
async function exists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
let saved = 0;
let written = 0;
async function compressOne(file: string): Promise<void> {
const buf = await readFile(file);
if (buf.length < MIN_BYTES) return;
const jobs: Promise<void>[] = [];
if (!(await exists(file + '.gz'))) {
jobs.push(
gzipAsync(buf, { level: zlib.Z_BEST_COMPRESSION }).then(async (out) => {
if (out.length < buf.length) {
await writeFile(file + '.gz', out);
written++;
saved += buf.length - out.length;
}
})
);
}
if (!(await exists(file + '.br'))) {
jobs.push(
brotliAsync(buf, {
params: {
[zlib.BROTLI_PARAM_QUALITY]: brotliQuality(buf.length),
[zlib.BROTLI_PARAM_SIZE_HINT]: buf.length
}
}).then(async (out) => {
if (out.length < buf.length) {
await writeFile(file + '.br', out);
written++;
saved += buf.length - out.length;
}
})
);
}
await Promise.all(jobs);
}
/** Run `tasks` with at most `limit` in flight at once. */
async function pool<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
await fn(items[idx]);
}
});
await Promise.all(workers);
}
const t0 = Date.now();
const files = await collect();
console.log(`[precompress] ${files.length} compressible files, ${CORES} cores`);
await pool(files, CORES, compressOne);
console.log(
`[precompress] wrote ${written} files, saved ${(saved / 1048576).toFixed(1)} MB in ${(
(Date.now() - t0) / 1000
).toFixed(1)}s`
);
+14 -34
View File
@@ -467,10 +467,11 @@ a:focus-visible {
/* ============================================
HIKES TRANSITIONS
Cards + filter fly in/out vertically, clicked card morphs into the hero
map (cross-fade between thumbnail and map), photo strip slides in from
the right. Page chrome under the hero cross-fades so nothing snaps in
at transition end. Lives in app.css (not the page component) so the
rules are still loaded on the OLD side of a nav AWAY from /hikes.
map (cross-fade between thumbnail and map), and the whole below-map panel
(an opaque sheet) slides up from the bottom. Page chrome under the hero
cross-fades so nothing snaps in at transition end. Lives in app.css (not
the page component) so the rules are still loaded on the OLD side of a
nav AWAY from /hikes.
============================================ */
@keyframes hikes-fly-up {
@@ -489,15 +490,6 @@ a:focus-visible {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes hike-strip-in-right {
from { transform: translateX(100vw); }
to { transform: translateX(0); }
}
@keyframes hike-strip-out-right {
from { transform: translateX(0); }
to { transform: translateX(100vw); }
}
/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit):
* kill UA's default fade, switch blend mode so the custom fly animation
* shows clean motion against the rest of the page. */
@@ -533,29 +525,17 @@ html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Photo strip slides in from the right when arriving at a detail page,
* and slides back out whenever the detail page is left for any other
* route (back to /hikes, off to /, /hikes/route-builder, …). Both exit
* scopes (vt-enter-hikes for the back-nav case, vt-exit-hike-detail for
* everywhere else) trigger the same animation. */
html.vt-enter-hike-detail::view-transition-new(hike-strip):only-child {
animation: hike-strip-in-right 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
html.vt-enter-hikes::view-transition-old(hike-strip):only-child,
html.vt-exit-hike-detail::view-transition-old(hike-strip):only-child {
animation: hike-strip-out-right 600ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Everything below the photo strip on a detail page (metrics, tags,
* elevation chart, scroll area, meta footer) slides up from the bottom
* on enter and back down on any exit. Wrapper element carries
* `view-transition-name: hike-below-strip`; the rest of the page chrome
* still cross-fades via the root-pseudo rule above. */
html.vt-enter-hike-detail::view-transition-new(hike-below-strip):only-child {
/* Everything below the hero map on a detail page — stage nav, photo strip,
* metrics, tags, elevation chart, scroll area, meta footer — slides up from
* the bottom on enter and back down on any exit, as one panel. The wrapper
* carries `view-transition-name: hike-below-map` and an opaque background, so
* the whole sheet (background included) moves; the hero map morphs separately
* above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
html.vt-enter-hikes::view-transition-old(hike-below-strip):only-child,
html.vt-exit-hike-detail::view-transition-old(hike-below-strip):only-child {
html.vt-enter-hikes::view-transition-old(hike-below-map):only-child,
html.vt-exit-hike-detail::view-transition-old(hike-below-map):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
-434
View File
@@ -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}>
+202 -123
View File
@@ -4,25 +4,50 @@
import { getStickerById } from '$lib/utils/stickers';
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameMonth, isToday, format, addMonths, subMonths
eachDayOfInterval, isSameMonth, isToday, isWeekend, format, addMonths, subMonths
} from 'date-fns';
import { de } from 'date-fns/locale';
let { completions = [], currentUser = '' } = $props();
let viewDate = $state(new Date());
// who-did-what colours (the household)
const PERSON_COLOR = /** @type {Record<string, string>} */ ({
anna: 'var(--nord15)',
alexander: 'var(--nord10)'
});
const personColor = /** @param {string} who */ (who) => PERSON_COLOR[who?.toLowerCase()] || 'var(--nord12)';
let filteredCompletions = $derived(
completions
.filter((/** @type {any} */ c) => c.stickerId)
.filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser)
);
// every sticker drop, both members
let drops = $derived(completions.filter((/** @type {any} */ c) => c.stickerId));
// Build a map: "YYYY-MM-DD" -> sticker ids[]
let stickersByDate = $derived.by(() => {
// Who's visible on the grid. Default: just the current user; others appear
// only when you tap their name in the tally.
let allPeople = $derived([...new Set(drops.map((/** @type {any} */ c) => c.completedBy))]);
let defaultShown = $derived(new Set(currentUser ? [currentUser] : allPeople));
/** @type {Set<string> | null} */
let manual = $state(null);
let shown = $derived(manual ?? defaultShown);
/** @param {string} who */
function toggle(who) {
const next = new Set(shown);
if (next.has(who)) next.delete(who);
else next.add(who);
manual = next;
}
/** @param {string} s */
function hash(s) {
let h = 0;
for (let i = 0; i < (s || '').length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
// "YYYY-MM-DD" -> completions[]
let byDate = $derived.by(() => {
/** @type {Map<string, any[]>} */
const map = new Map();
for (const c of filteredCompletions) {
for (const c of drops) {
if (!shown.has(c.completedBy)) continue;
const key = format(new Date(c.completedAt), 'yyyy-MM-dd');
if (!map.has(key)) map.set(key, []);
map.get(key)?.push(c);
@@ -31,59 +56,89 @@
});
let calendarDays = $derived.by(() => {
const monthStart = startOfMonth(viewDate);
const monthEnd = endOfMonth(viewDate);
const calStart = startOfWeek(monthStart, { locale: de });
const calEnd = endOfWeek(monthEnd, { locale: de });
const calStart = startOfWeek(startOfMonth(viewDate), { locale: de });
const calEnd = endOfWeek(endOfMonth(viewDate), { locale: de });
return eachDayOfInterval({ start: calStart, end: calEnd });
});
let viewDate = $state(new Date());
let monthLabel = $derived(format(viewDate, 'MMMM yyyy', { locale: de }));
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// per-person tally for the visible month
let tally = $derived.by(() => {
/** @type {Map<string, number>} */
const m = new Map();
for (const c of drops) {
if (!isSameMonth(new Date(c.completedAt), viewDate)) continue;
m.set(c.completedBy, (m.get(c.completedBy) || 0) + 1);
}
return [...m.entries()].sort((a, b) => b[1] - a[1]);
});
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function prevMonth() { viewDate = subMonths(viewDate, 1); }
function nextMonth() { viewDate = addMonths(viewDate, 1); }
</script>
<div class="cal-container">
<div class="cal-page">
<span class="tape tape-l" aria-hidden="true"></span>
<span class="tape tape-r" aria-hidden="true"></span>
<div class="cal-header">
<button class="cal-nav" onclick={prevMonth}><ChevronLeft size={18} /></button>
<button class="cal-nav" onclick={prevMonth} aria-label="Voriger Monat"><ChevronLeft size={18} /></button>
<span class="cal-month">{monthLabel}</span>
<button class="cal-nav" onclick={nextMonth}><ChevronRight size={18} /></button>
<button class="cal-nav" onclick={nextMonth} aria-label="Nächster Monat"><ChevronRight size={18} /></button>
</div>
{#if tally.length > 0}
<div class="tally">
{#each tally as [who, n] (who)}
<button
type="button"
class="tally-chip"
class:active={shown.has(who)}
class:me={who === currentUser}
style="--pc: {personColor(who)}"
title="{shown.has(who) ? 'Ausblenden' : 'Einblenden'}"
aria-pressed={shown.has(who)}
onclick={() => toggle(who)}
>
<span class="dot"></span>{who}<strong>{n}</strong>
</button>
{/each}
</div>
{/if}
<div class="cal-grid">
{#each weekdays as day}
{#each weekdays as day (day)}
<div class="cal-weekday">{day}</div>
{/each}
{#each calendarDays as day}
{#each calendarDays as day (day.toISOString())}
{@const key = format(day, 'yyyy-MM-dd')}
{@const dayStickers = stickersByDate.get(key) || []}
{@const dayDrops = byDate.get(key) || []}
{@const inMonth = isSameMonth(day, viewDate)}
<div
class="cal-day"
class:outside={!inMonth}
class:weekend={isWeekend(day)}
class:today={isToday(day)}
class:has-stickers={dayStickers.length > 0}
>
<span class="cal-day-num">{format(day, 'd')}</span>
{#if dayStickers.length > 0}
<div class="cal-stickers">
{#each dayStickers.slice(0, 6) as completion}
{@const sticker = getStickerById(completion.stickerId)}
{#if dayDrops.length > 0}
<div class="stuck">
{#each dayDrops.slice(0, 4) as c (c._id)}
{@const sticker = getStickerById(c.stickerId)}
{#if sticker}
<img
class="cal-sticker-img"
src="/stickers/{sticker.image}"
alt={sticker.name}
title="{sticker.name} {completion.taskTitle}"
/>
{@const tilt = (hash(c._id) % 13) - 6}
<span class="cat" style="--tilt: {tilt}deg; --pc: {personColor(c.completedBy)}">
<img src="/stickers/{sticker.image}" alt={sticker.name} title="{sticker.name} {c.taskTitle} ({c.completedBy})" loading="lazy" />
<span class="who-dot"></span>
</span>
{/if}
{/each}
{#if dayStickers.length > 6}
<span class="cal-more">+{dayStickers.length - 6}</span>
{#if dayDrops.length > 4}
<span class="more">+{dayDrops.length - 4}</span>
{/if}
</div>
{/if}
@@ -93,27 +148,48 @@
</div>
<style>
.cal-container {
background: var(--color-bg-primary, white);
border: 1px solid var(--color-border, #e8e4dd);
border-radius: 14px;
padding: 1rem;
/* warm paper page (matches the sticker album) — stays cream in both themes */
.cal-page {
position: relative;
margin-bottom: 2rem;
padding: 1.25rem 1rem 1.4rem;
border-radius: var(--radius-lg);
background-color: #f3ecd9;
background-image: radial-gradient(rgba(120, 100, 70, 0.16) 1px, transparent 1.4px);
background-size: 18px 18px;
border: 1px solid #e4d9be;
box-shadow: var(--shadow-sm), inset 0 0 50px rgba(150, 130, 90, 0.08);
}
:global(:root[data-theme='dark']) .cal-page,
:global(:root:not([data-theme='light'])) .cal-page { background-color: #ece3cb; }
/* washi tape holding the page up */
.tape {
position: absolute;
top: -10px;
width: 78px;
height: 24px;
background: repeating-linear-gradient(45deg, rgba(136, 192, 208, 0.45) 0 7px, rgba(136, 192, 208, 0.28) 7px 14px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.tape-l { left: 26px; transform: rotate(-5deg); }
.tape-r { right: 26px; transform: rotate(4deg); background: repeating-linear-gradient(45deg, rgba(235, 203, 139, 0.5) 0 7px, rgba(235, 203, 139, 0.3) 7px 14px); }
.cal-header {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
.cal-month {
font-size: 1rem;
font-family: 'Fredoka', Helvetica, sans-serif;
font-size: 1.5rem;
font-weight: 700;
text-transform: capitalize;
min-width: 160px;
min-width: 180px;
text-align: center;
color: #5a4a2c;
}
.cal-nav {
display: flex;
@@ -123,132 +199,135 @@
height: 32px;
border: none;
background: transparent;
color: var(--color-text-secondary, #888);
color: #8a7747;
border-radius: 8px;
cursor: pointer;
transition: all 120ms;
}
.cal-nav:hover {
background: var(--color-bg-secondary, #f0ede6);
color: var(--color-text-primary, #333);
.cal-nav:hover { background: rgba(138, 119, 71, 0.14); color: #5a4a2c; }
.tally {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.8rem;
}
.tally-chip {
display: inline-flex;
align-items: center;
gap: 0.32rem;
padding: 0.18rem 0.6rem;
font-size: 0.74rem;
font-weight: 600;
color: #5a4a2c;
background: rgba(255, 255, 255, 0.4);
border: 1px solid color-mix(in srgb, var(--pc) 30%, transparent);
border-radius: var(--radius-pill);
text-transform: capitalize;
cursor: pointer;
opacity: 0.5;
transition: opacity 120ms, background 120ms, border-color 120ms, transform 120ms;
}
.tally-chip:hover { opacity: 0.85; transform: translateY(-1px); }
.tally-chip.active {
opacity: 1;
background: color-mix(in srgb, var(--pc) 16%, rgba(255, 255, 255, 0.6));
border-color: color-mix(in srgb, var(--pc) 55%, transparent);
}
.tally-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pc); }
.tally-chip strong { font-family: 'Fredoka', Helvetica, sans-serif; color: var(--pc); }
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
gap: 2px;
}
.cal-weekday {
text-align: center;
font-size: 0.68rem;
font-size: 0.66rem;
font-weight: 700;
color: var(--color-text-secondary, #999);
padding: 0.3rem 0;
color: #9a865a;
padding: 0.2rem 0;
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.05em;
}
.cal-day {
position: relative;
min-height: 80px;
min-height: 78px;
padding: 0.3rem;
border-radius: 8px;
border: 1px solid transparent;
transition: background 120ms;
}
.cal-day.outside {
opacity: 0.25;
}
.cal-day.today {
background: rgba(94, 129, 172, 0.08);
border-color: rgba(94, 129, 172, 0.2);
border: 1px dashed transparent;
}
.cal-day.weekend { background: rgba(150, 130, 90, 0.07); }
.cal-day.outside { opacity: 0.3; }
.cal-day.today { border-color: var(--nord10); background: rgba(94, 129, 172, 0.1); }
.cal-day.today .cal-day-num {
background: var(--nord10);
color: white;
color: #fff;
border-radius: 50%;
width: 20px;
height: 20px;
width: 19px;
height: 19px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.cal-day.has-stickers {
background: rgba(163, 190, 140, 0.06);
}
.cal-day-num {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-secondary, #888);
font-weight: 700;
color: #8a7747;
line-height: 1;
display: block;
margin-bottom: 0.2rem;
margin-bottom: 0.25rem;
}
.cal-stickers {
.stuck {
display: flex;
flex-wrap: wrap;
gap: 3px;
gap: 3px 2px;
align-items: center;
}
.cal-sticker-img {
width: 28px;
height: 28px;
object-fit: contain;
transition: transform 150ms;
/* a cat sticker "stuck" on the date — die-cut white edge + hand tilt */
.cat {
position: relative;
transform: rotate(var(--tilt));
transition: transform 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: default;
}
.cal-sticker-img:hover {
transform: scale(2);
z-index: 10;
position: relative;
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));
.cat img {
display: block;
width: 27px;
height: 27px;
object-fit: contain;
filter:
drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff)
drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff)
drop-shadow(0 2px 2px rgba(0, 0, 0, 0.22));
}
.cal-more {
.cat:hover { transform: rotate(0deg) scale(1.9); z-index: 10; }
.who-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--pc);
border: 1.5px solid #f3ecd9;
}
.more {
font-size: 0.6rem;
font-weight: 700;
color: var(--color-text-secondary, #aaa);
display: flex;
align-items: center;
padding-left: 2px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .cal-container {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .cal-nav:hover {
background: var(--nord2);
}
:global(:root:not([data-theme="light"])) .cal-day.today {
background: rgba(94, 129, 172, 0.12);
}
:global(:root:not([data-theme="light"])) .cal-day.has-stickers {
background: rgba(163, 190, 140, 0.08);
}
}
:global(:root[data-theme="dark"]) .cal-container {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .cal-nav:hover {
background: var(--nord2);
}
:global(:root[data-theme="dark"]) .cal-day.today {
background: rgba(94, 129, 172, 0.12);
}
:global(:root[data-theme="dark"]) .cal-day.has-stickers {
background: rgba(163, 190, 140, 0.08);
color: #9a865a;
align-self: center;
padding-left: 1px;
}
@media (max-width: 500px) {
.cal-day { min-height: 56px; padding: 0.2rem; }
.cal-sticker-img { width: 22px; height: 22px; }
.cal-stickers { gap: 2px; }
.cal-month { font-size: 0.9rem; min-width: 130px; }
.cal-day { min-height: 58px; padding: 0.2rem; }
.cat img { width: 21px; height: 21px; }
.cal-month { font-size: 1.25rem; min-width: 140px; }
.tape { display: none; }
}
</style>
@@ -0,0 +1,208 @@
<script>
import { getRarityColor } from '$lib/utils/stickers';
let { sticker, count = 0, owned = false, onpick } = $props();
const rarityLabels = /** @type {Record<string, string>} */ ({
common: 'Gewöhnlich',
uncommon: 'Ungewöhnlich',
rare: 'Selten',
legendary: 'Legendär'
});
const foilByRarity = /** @type {Record<string, number>} */ ({
common: 0,
uncommon: 0.22,
rare: 0.6,
legendary: 1
});
/** @param {string} s */
function hash(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
let tilt = $derived((hash(sticker.id) % 9) - 4); // -4deg .. 4deg, hand-placed
/** @type {HTMLElement | undefined} */
let el = $state();
let mx = $state(50), my = $state(50), active = $state(false);
/** @param {PointerEvent} e */
function onmove(e) {
if (!el) return;
const r = el.getBoundingClientRect();
mx = Math.round(((e.clientX - r.left) / r.width) * 100);
my = Math.round(((e.clientY - r.top) / r.height) * 100);
active = true;
}
function leave() {
mx = 50; my = 50; active = false;
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="slot"
class:owned
bind:this={el}
role={owned ? 'button' : undefined}
tabindex={owned ? 0 : undefined}
onpointermove={onmove}
onpointerleave={leave}
onclick={() => owned && onpick?.(sticker)}
onkeydown={(e) => owned && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onpick?.(sticker))}
style="--tilt: {tilt}deg; --mx: {mx}%; --my: {my}%; --m: url('/stickers/{sticker.image}'); --foil: {owned ? foilByRarity[sticker.rarity] : 0}; --on: {active ? 1 : 0}; --rarity: {getRarityColor(sticker.rarity)};"
title={owned ? `${sticker.name} ${rarityLabels[sticker.rarity]}` : 'Noch nicht gesammelt'}
>
{#if owned}
<div class="vinyl rarity-{sticker.rarity}">
<span class="glow" aria-hidden="true"></span>
<img src="/stickers/{sticker.image}" alt={sticker.name} loading="lazy" />
<span class="sheen" aria-hidden="true"></span>
<span class="foil" aria-hidden="true"></span>
{#if count > 1}<span class="dupes">×{count}</span>{/if}
</div>
<span class="label">{sticker.name}</span>
{:else}
<div class="deboss" aria-hidden="true"></div>
<span class="label empty">?</span>
{/if}
</div>
<style>
.slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.2rem;
}
/* ---------- owned: die-cut glossy vinyl ---------- */
.vinyl {
position: relative;
width: 78px;
height: 78px;
transform: rotate(var(--tilt));
transition: transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1), filter 180ms;
cursor: pointer;
}
.vinyl img {
width: 100%;
height: 100%;
object-fit: contain;
/* white die-cut border + contact shadow */
filter:
drop-shadow(1.4px 0 0 #fff) drop-shadow(-1.4px 0 0 #fff)
drop-shadow(0 1.4px 0 #fff) drop-shadow(0 -1.4px 0 #fff)
drop-shadow(0 3px 3px rgba(0, 0, 0, 0.28));
}
.slot:hover .vinyl {
transform: rotate(0deg) translateY(-4px) scale(1.06);
}
/* rarity aura behind the sticker (scales with grade) */
.glow {
position: absolute;
inset: -14%;
z-index: -1;
border-radius: 50%;
background: radial-gradient(circle, var(--rarity), transparent 62%);
opacity: calc(var(--foil) * (0.3 + 0.35 * var(--on)));
filter: blur(5px);
}
.rarity-legendary .glow { animation: pulse 2.8s ease-in-out infinite; }
/* glossy specular sweep, clipped to the sticker shape */
.sheen, .foil {
position: absolute;
inset: 0;
pointer-events: none;
-webkit-mask: var(--m) center / contain no-repeat;
mask: var(--m) center / contain no-repeat;
}
.sheen {
background: radial-gradient(35% 35% at var(--mx) var(--my), rgba(255, 255, 255, 0.85), transparent 60%),
linear-gradient(120deg, transparent 40%, rgba(255, 255, 255, 0.5) 50%, transparent 60%);
background-size: 100% 100%, 220% 220%;
background-position: 0 0, var(--mx) var(--my);
opacity: calc(0.35 + 0.45 * var(--on));
mix-blend-mode: overlay;
}
/* periodic light sweep for rare+ stickers even at rest */
.rarity-rare .sheen, .rarity-legendary .sheen {
animation: sweep 4.5s ease-in-out infinite;
}
/* holographic foil for rarer stickers — always shimmers, intensifies on hover */
.foil {
background: repeating-linear-gradient(
115deg,
rgba(0, 231, 255, 0.55) 0%,
rgba(255, 0, 231, 0.55) 7%,
rgba(255, 245, 0, 0.55) 14%,
rgba(0, 231, 255, 0.55) 21%
);
background-size: 250% 250%;
background-position: var(--mx) var(--my);
mix-blend-mode: color-dodge;
opacity: calc(var(--foil) * (0.3 + 0.55 * var(--on)));
animation: holo 5s linear infinite;
}
/* when the pointer is on the card, follow it instead of auto-drifting */
.slot:hover .foil { animation-play-state: paused; }
@keyframes holo {
0% { background-position: 0% 50%; }
100% { background-position: 250% 50%; }
}
@keyframes sweep {
0%, 100% { background-position: 0 0, -60% 0; }
50% { background-position: 0 0, 160% 0; }
}
@keyframes pulse {
0%, 100% { opacity: calc(var(--foil) * 0.3); transform: scale(1); }
50% { opacity: calc(var(--foil) * 0.5); transform: scale(1.06); }
}
.dupes {
position: absolute;
bottom: -2px;
right: -4px;
padding: 0.02rem 0.32rem;
font-family: 'Fredoka', Helvetica, sans-serif;
font-size: 0.6rem;
font-weight: 700;
color: #fff;
background: var(--nord10);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-sm);
}
/* ---------- missing: debossed silhouette pressed into the page ---------- */
.deboss {
width: 70px;
height: 70px;
/* fixed paper tones — the album sheet stays cream in both themes */
background: rgba(90, 74, 44, 0.22);
-webkit-mask: var(--m) center / contain no-repeat;
mask: var(--m) center / contain no-repeat;
filter: drop-shadow(0 1.5px 0.5px rgba(255, 255, 255, 0.7));
opacity: 0.85;
}
.label {
font-size: 0.62rem;
text-align: center;
color: #6a5a3a;
max-width: 92px;
line-height: 1.1;
}
.label.empty { color: #b0a07c; font-weight: 700; }
@media (prefers-reduced-motion: reduce) {
.vinyl { transition: none; }
.foil, .sheen, .glow { animation: none !important; }
}
</style>
@@ -0,0 +1,181 @@
<script>
import { scale, fade } from 'svelte/transition';
import { elasticOut } from 'svelte/easing';
import { getRarityColor } from '$lib/utils/stickers';
let { sticker, count = 0, firstEarnedLabel = '', sourceTask = '', onclose } = $props();
const rarityLabels = /** @type {Record<string, string>} */ ({
common: 'Gewöhnlich',
uncommon: 'Ungewöhnlich',
rare: 'Selten',
legendary: 'Legendär'
});
const foilByRarity = /** @type {Record<string, number>} */ ({
common: 0,
uncommon: 0.25,
rare: 0.65,
legendary: 1
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="backdrop" transition:fade={{ duration: 180 }} onclick={onclose} onkeydown={(e) => e.key === 'Escape' && onclose?.()}>
<div
class="card"
transition:scale={{ start: 0.85, duration: 320, easing: elasticOut }}
style="--rarity: {getRarityColor(sticker.rarity)}; --foil: {foilByRarity[sticker.rarity]};"
onclick={(e) => e.stopPropagation()}
>
<div class="stage">
<div class="vinyl">
<img src="/stickers/{sticker.image}" alt={sticker.name} />
<span class="foil" style="--m: url('/stickers/{sticker.image}');" aria-hidden="true"></span>
</div>
</div>
<h2 class="title">{sticker.name}</h2>
<span class="rarity-badge">{rarityLabels[sticker.rarity]}</span>
<p class="desc">{sticker.description}</p>
<dl class="stats">
<div><dt>Anzahl</dt><dd>×{count}</dd></div>
<div><dt>Zuerst erhalten</dt><dd>{firstEarnedLabel || '—'}</dd></div>
<div><dt>Quelle</dt><dd>{sourceTask || '—'}</dd></div>
</dl>
<button class="close" onclick={onclose}>Schließen</button>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
padding: 1rem;
}
.card {
position: relative;
width: 100%;
max-width: 340px;
padding: 1.5rem 1.5rem 1.25rem;
text-align: center;
border-radius: var(--radius-card);
background:
radial-gradient(120% 70% at 50% 0%, color-mix(in srgb, var(--rarity) 22%, var(--color-surface)), var(--color-surface));
border: 2px solid color-mix(in srgb, var(--rarity) 60%, transparent);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
}
.stage {
display: flex;
align-items: center;
justify-content: center;
height: 170px;
margin-bottom: 0.5rem;
}
.stage::before {
content: '';
position: absolute;
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, var(--rarity), transparent 65%);
opacity: 0.4;
}
.vinyl { position: relative; width: 150px; height: 150px; }
.vinyl img {
width: 100%;
height: 100%;
object-fit: contain;
/* die-cut white border + drop shadow */
filter:
drop-shadow(2px 0 0 #fff) drop-shadow(-2px 0 0 #fff)
drop-shadow(0 2px 0 #fff) drop-shadow(0 -2px 0 #fff)
drop-shadow(0 6px 7px rgba(0, 0, 0, 0.3));
}
.foil {
position: absolute;
inset: 0;
pointer-events: none;
-webkit-mask: var(--m) center / contain no-repeat;
mask: var(--m) center / contain no-repeat;
background: repeating-linear-gradient(
115deg,
rgba(0, 231, 255, 0.55) 0%,
rgba(255, 0, 231, 0.55) 7%,
rgba(255, 245, 0, 0.55) 14%,
rgba(0, 231, 255, 0.55) 21%
);
background-size: 250% 250%;
mix-blend-mode: color-dodge;
opacity: var(--foil);
animation: shift 6s linear infinite;
}
@keyframes shift {
to { background-position: 250% 0; }
}
.title {
margin: 0;
font-family: 'Fredoka', Helvetica, sans-serif;
font-weight: 700;
font-size: 1.85rem;
line-height: 1.1;
color: var(--color-text-primary);
}
.rarity-badge {
display: inline-block;
margin-top: 0.3rem;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--rarity);
}
.desc {
margin: 0.5rem 0 1rem;
font-size: 0.88rem;
font-style: italic;
color: var(--color-text-secondary);
}
.stats {
margin: 0 0 1.25rem;
text-align: left;
font-size: 0.82rem;
}
.stats div {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.2rem;
border-bottom: 1px solid var(--color-border);
}
.stats dt { color: var(--color-text-secondary); }
.stats dd { margin: 0; font-weight: 600; color: var(--color-text-primary); text-align: right; }
.close {
padding: 0.55rem 2rem;
border: none;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: var(--color-text-on-primary);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast);
}
.close:hover { background: var(--color-primary-hover); }
@media (prefers-reduced-motion: reduce) {
.foil { animation: none; }
}
</style>
+81
View File
@@ -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` (1100). 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`;
}
+12
View File
@@ -172,6 +172,18 @@ const DIFFICULTY_RARITY_WEIGHTS: Record<string, Record<string, number>> = {
high: { common: 25, uncommon: 30, rare: 30, legendary: 15 },
};
// Categories that can drop from ANY task (see getStickerForTags below).
export const ALWAYS_CATEGORIES = ['general', 'achievement', 'cozy', 'special'];
// Reverse of TAG_CATEGORY_MAP: which task tags can drop a given category.
export function getTagsForCategory(category: string): string[] {
const tags: string[] = [];
for (const [tag, cats] of Object.entries(TAG_CATEGORY_MAP)) {
if (cats.includes(category)) tags.push(tag);
}
return tags;
}
export function getStickerForTags(tags: string[], difficulty?: string): Sticker {
const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium;
+4 -4
View File
@@ -80,10 +80,10 @@
// card pairs into the hero and the rest fly out.)
// - vt-enter-hike-detail: arriving at a hike detail page (card → zoom).
// - vt-exit-hike-detail: leaving a hike detail page for anywhere
// else (back to /hikes, off to /, route-builder, …) → photo strip
// slides back out to the right and the below-strip block flies
// down. Excluded for slug → slug navigations (both sides share the
// same route.id, so paired UA transitions handle them).
// else (back to /hikes, off to /, route-builder, …) → the whole
// below-map panel flies back down off the bottom. Excluded for
// slug → slug navigations (both sides share the same route.id, so
// paired UA transitions handle them).
const intoHikesIndex = toId === '/hikes' && fromId !== '/hikes';
const outOfHikesIndex = fromId === '/hikes' && toId !== '/hikes';
const intoHikeDetail = toId === '/hikes/[slug]';
@@ -7,7 +7,8 @@ import { processAndSaveRecipeImage } from '$utils/imageProcessing';
import {
extractRecipeFromFormData,
validateRecipeData,
serializeRecipeForDatabase
serializeRecipeForDatabase,
serializableFormValues
} from '$utils/recipeFormHelpers';
export const load: PageServerLoad = async ({locals, params}) => {
@@ -51,7 +52,7 @@ export const actions = {
return fail(400, {
error: validationErrors.join(', '),
errors: validationErrors,
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
@@ -87,7 +88,7 @@ export const actions = {
return fail(400, {
error: `Failed to process image: ${message}`,
errors: ['Image processing failed'],
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
} else {
@@ -127,7 +128,7 @@ export const actions = {
return fail(400, {
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
errors: ['Duplicate short_name'],
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
@@ -135,7 +136,7 @@ export const actions = {
return fail(500, {
error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`,
errors: [dbMessage],
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
} catch (error: unknown) {
@@ -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
@@ -13,7 +13,8 @@ import {
extractRecipeFromFormData,
validateRecipeData,
serializeRecipeForDatabase,
detectChangedFields
detectChangedFields,
serializableFormValues
} from '$utils/recipeFormHelpers';
/**
@@ -98,7 +99,7 @@ export const actions = {
return fail(400, {
error: 'Original short name is required for edit',
errors: ['Missing original_short_name'],
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
@@ -108,7 +109,7 @@ export const actions = {
return fail(400, {
error: validationErrors.join(', '),
errors: validationErrors,
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
@@ -143,7 +144,7 @@ export const actions = {
return fail(400, {
error: `Failed to process image: ${message}`,
errors: ['Image processing failed'],
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
} else if (keepExistingImage && existingImagePath) {
@@ -206,7 +207,7 @@ export const actions = {
return fail(404, {
error: `Recipe with short name "${originalShortName}" not found`,
errors: ['Recipe not found'],
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
@@ -263,7 +264,7 @@ export const actions = {
return fail(400, {
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
errors: ['Duplicate short_name'],
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
@@ -271,7 +272,7 @@ export const actions = {
return fail(500, {
error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`,
errors: [dbMessage],
values: Object.fromEntries(formData)
values: serializableFormValues(formData)
});
}
} catch (error: unknown) {
@@ -3,7 +3,7 @@ import { Task } from '$models/Task';
import { TaskCompletion } from '$models/TaskCompletion';
import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
import { getStickerForTags } from '$lib/utils/stickers';
import { getStickerForTags, getStickerById } from '$lib/utils/stickers';
import { addDays } from 'date-fns';
function getNextDueDate(completedAt: Date, frequencyType: string, customDays?: number): Date {
@@ -37,8 +37,11 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const now = new Date();
// Award a sticker based on task tags and difficulty
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
// Award a sticker. The client rolls + displays it optimistically and passes
// the id here; fall back to a server-side roll if it's missing or invalid.
const sticker =
(typeof body.stickerId === 'string' && getStickerById(body.stickerId)) ||
getStickerForTags(task.tags, task.difficulty || 'medium');
// Record the completion
const completion = await TaskCompletion.create({
+16 -6
View File
@@ -415,21 +415,22 @@
</div>
</section>
<!-- Everything below the hero map — stage nav, photo strip, metrics,
tags, elevation chart, scroll area, footer — is wrapped in one panel
so view-transitions slide the whole block (with its own background)
up from the bottom on enter and down on exit. The hero map morphs
separately above this. -->
<div class="below-map" style="view-transition-name: hike-below-map">
{#if hasStages && stages}
<HikeStageNav {stages} />
{/if}
{#if track && track.length > 0 && visibleImagePoints.length > 0}
<section class="strip-area" style="view-transition-name: hike-strip">
<section class="strip-area">
<HikePhotoStrip images={visibleImagePoints} {track} {stages} />
</section>
{/if}
<!-- Everything below the photo strip is wrapped so view-transitions
can slide the whole block (metrics, tags, elevation chart, scroll
area, footer) up from the bottom on enter and down on exit. The
hero map and strip animate separately above this. -->
<div class="below-strip" style="view-transition-name: hike-below-strip">
<section class="metrics" aria-label="Tourendaten">
{#if hike.icon}
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
@@ -985,6 +986,15 @@
opacity: 0.55;
}
/* The whole below-the-map block. The solid background makes its
view-transition snapshot an opaque panel, so on enter/exit the entire
sheet (background included) slides up/down from the bottom rather than
just the metric tiles appearing to float. */
.below-map {
position: relative;
background: var(--color-bg-primary);
}
.strip-area {
padding-inline: 1rem;
margin-top: 0.5rem;
+15 -5
View File
@@ -27,6 +27,7 @@
import { flip } from 'svelte/animate';
import TaskForm from '$lib/components/tasks/TaskForm.svelte';
import StickerPopup from '$lib/components/tasks/StickerPopup.svelte';
import { getStickerForTags } from '$lib/utils/stickers';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
let { data } = $props();
@@ -112,16 +113,25 @@
* @param {string} [forUser]
*/
async function completeTask(task, forUser) {
// Roll the sticker client-side and show it immediately — don't wait on the
// POST roundtrip (DB writes) just to learn which sticker to display.
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
// Warm the image cache so the cat is decoded by the time the popup finishes
// its bounce-in, instead of fading into an empty circle.
if (typeof Image !== 'undefined') {
const img = new Image();
img.src = `/stickers/${sticker.image}`;
}
awardedSticker = sticker;
completeForTaskId = null;
// Persist in the background; tell the server which sticker we showed.
const res = await fetch(`/api/tasks/${task._id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(forUser ? { completedFor: forUser } : {})
body: JSON.stringify({ stickerId: sticker.id, ...(forUser ? { completedFor: forUser } : {}) })
});
if (!res.ok) return;
const result = await res.json();
awardedSticker = result.sticker;
completeForTaskId = null;
await refreshTasks();
}
+201 -177
View File
@@ -1,65 +1,100 @@
<script>
import { invalidateAll } from '$app/navigation';
import { confirm } from '$lib/js/confirmDialog.svelte';
import { STICKERS, getStickerById, getRarityColor } from '$lib/utils/stickers';
import { formatDistanceToNow } from 'date-fns';
import { STICKERS, getStickerById, ALWAYS_CATEGORIES, getTagsForCategory } from '$lib/utils/stickers';
import { formatDistanceToNow, format } from 'date-fns';
import { de } from 'date-fns/locale';
import { scale } from 'svelte/transition';
import { flip } from 'svelte/animate';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Wind from '@lucide/svelte/icons/wind';
import Brush from '@lucide/svelte/icons/brush';
import Bath from '@lucide/svelte/icons/bath';
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
import CookingPot from '@lucide/svelte/icons/cooking-pot';
import Droplets from '@lucide/svelte/icons/droplets';
import WashingMachine from '@lucide/svelte/icons/washing-machine';
import Shirt from '@lucide/svelte/icons/shirt';
import Flower2 from '@lucide/svelte/icons/flower-2';
import Leaf from '@lucide/svelte/icons/leaf';
import ShoppingCart from '@lucide/svelte/icons/shopping-cart';
import StickerCalendar from '$lib/components/tasks/StickerCalendar.svelte';
import StickerPopup from '$lib/components/tasks/StickerPopup.svelte';
import VinylSticker from '$lib/components/tasks/VinylSticker.svelte';
import VinylStickerCard from '$lib/components/tasks/VinylStickerCard.svelte';
let { data } = $props();
/** @type {import('$lib/utils/stickers').Sticker | null} */
let selectedSticker = $state(null);
let selected = $state(null);
let stats = $derived(data.stats || { userStats: [], userStickers: [], recentCompletions: [] });
let currentUser = $derived(data.session?.user?.nickname || '');
const rarityLabels = /** @type {Record<string, string>} */ ({
common: 'Gewöhnlich',
uncommon: 'Ungewöhnlich',
rare: 'Selten',
legendary: 'Legendär'
});
const rarityOrder = /** @type {Record<string, number>} */ ({
legendary: 0,
rare: 1,
uncommon: 2,
common: 3
});
// Build current user's sticker collection
let displayedStickers = $derived.by(() => {
// id -> times earned (current user)
let counts = $derived.by(() => {
/** @type {Map<string, number>} */
const collection = new Map();
const m = new Map();
for (const entry of stats.userStickers) {
if (entry._id.user === currentUser) {
collection.set(entry._id.sticker, entry.count);
}
if (entry._id.user === currentUser) m.set(entry._id.sticker, entry.count);
}
return collection;
return m;
});
// Sort stickers for display: owned first (by rarity), then unowned
let sortedStickers = $derived.by(() => {
return [...STICKERS].sort((a, b) => {
const aOwned = displayedStickers.has(a.id);
const bOwned = displayedStickers.has(b.id);
if (aOwned && !bOwned) return -1;
if (!aOwned && bOwned) return 1;
const rarityDiff = (rarityOrder[a.rarity] ?? 3) - (rarityOrder[b.rarity] ?? 3);
if (rarityDiff !== 0) return rarityDiff;
return a.name.localeCompare(b.name, 'de');
});
// album "pages" by category
const PAGES = [
{ cat: 'general', name: 'Allerlei' },
{ cat: 'kitchen', name: 'Küche' },
{ cat: 'cozy', name: 'Gemütlichkeit' },
{ cat: 'plants', name: 'Pflanzen & Garten' },
{ cat: 'cleaning', name: 'Sauberkeit' },
{ cat: 'errands', name: 'Erledigungen' },
{ cat: 'achievement', name: 'Erfolge' },
{ cat: 'special', name: 'Besonderes' }
];
const rarityRank = /** @type {Record<string, number>} */ ({ legendary: 0, rare: 1, uncommon: 2, common: 3 });
let pages = $derived(
PAGES.map((p) => {
const items = STICKERS.filter((s) => s.category === p.cat).sort(
(a, b) => (rarityRank[a.rarity] ?? 9) - (rarityRank[b.rarity] ?? 9) || a.name.localeCompare(b.name, 'de')
);
// category rank = average sticker rarity (lower = rarer -> higher up);
// 'general' is the catch-all bucket, so it always sinks to the bottom
const avg = items.reduce((sum, s) => sum + (rarityRank[s.rarity] ?? 9), 0) / (items.length || 1);
const score = p.cat === 'general' ? 99 : avg;
const always = ALWAYS_CATEGORIES.includes(p.cat);
const tags = always ? [] : getTagsForCategory(p.cat);
return { ...p, items, score, always, tags, owned: items.filter((s) => counts.has(s.id)).length };
}).sort((a, b) => a.score - b.score)
);
// id -> { first earned label, source task } (recentCompletions is newest-first)
let info = $derived.by(() => {
/** @type {Map<string, { first: string, task: string }>} */
const m = new Map();
for (const c of stats.recentCompletions || []) {
if (c.completedBy !== currentUser || !c.stickerId) continue;
m.set(c.stickerId, {
first: format(new Date(c.completedAt), 'd. MMM yyyy', { locale: de }),
task: c.taskTitle || ''
});
}
return m;
});
let collectedCount = $derived(displayedStickers.size);
let collectedCount = $derived(counts.size);
let totalCount = STICKERS.length;
let openInfo = $state('');
// same tag icons as the /tasks page
/** @type {Record<string, any>} */
const TAG_ICONS = {
putzen: Sparkles, saugen: Wind, wischen: Brush, bad: Bath,
küche: UtensilsCrossed, kochen: CookingPot, abwasch: Droplets,
wäsche: WashingMachine, bügeln: Shirt,
pflanzen: Flower2, gießen: Droplets, düngen: Leaf, garten: Leaf,
einkaufen: ShoppingCart, müll: Trash2
};
// Recent completions with stickers
let recentWithStickers = $derived(
stats.recentCompletions
@@ -89,54 +124,59 @@
<div class="progress-bar">
<div class="progress-fill" style="width: {(collectedCount / totalCount) * 100}%"></div>
</div>
</header>
<StickerCalendar completions={stats.recentCompletions} {currentUser} />
<h2 class="section-title">Alle Sticker</h2>
<div class="sticker-grid">
{#each sortedStickers as sticker (sticker.id)}
{@const count = displayedStickers.get(sticker.id) || 0}
{@const owned = count > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="sticker-card"
class:owned
class:locked={!owned}
animate:flip={{ duration: 300 }}
style="--rarity-color: {getRarityColor(sticker.rarity)}"
onclick={() => owned && (selectedSticker = sticker)}
>
<div class="sticker-visual">
{#if owned}
<img class="sticker-img" src="/stickers/{sticker.image}" alt={sticker.name} />
{:else}
<span class="sticker-unknown">?</span>
{/if}
{#if count > 1}
<span class="sticker-count">x{count}</span>
{/if}
</div>
<div class="sticker-info">
<span class="sticker-name">{owned ? sticker.name : '???'}</span>
<span class="sticker-rarity" style="color: {getRarityColor(sticker.rarity)}">
{rarityLabels[sticker.rarity]}
</span>
{#if owned}
<span class="sticker-desc">{sticker.description}</span>
{/if}
{#each pages as page (page.cat)}
<section class="page">
<div class="page-head">
<div class="ph-title">
<h3>{page.name}</h3>
<button
class="info-btn"
class:open={openInfo === page.cat}
aria-label="Wie bekomme ich diese Sticker?"
aria-expanded={openInfo === page.cat}
onclick={() => (openInfo = openInfo === page.cat ? '' : page.cat)}
>i</button>
</div>
<span class="page-count">{page.owned}/{page.items.length}</span>
</div>
{/each}
</div>
{#if openInfo === page.cat}
<p class="earn-info">
{#if page.always}
Diese Kätzchen können bei <strong>jeder erledigten Aufgabe</strong> auftauchen.
{:else}
Tauchen bei Aufgaben mit diesen Tags auf:
<span class="tags">
{#each page.tags as t (t)}
{@const Icon = TAG_ICONS[t]}
<span class="tag">{#if Icon}<Icon size={13} strokeWidth={1.8} />{/if}{t}</span>
{/each}
</span>
{/if}
</p>
{/if}
<div class="sheet">
{#each page.items as sticker (sticker.id)}
<VinylSticker
{sticker}
owned={counts.has(sticker.id)}
count={counts.get(sticker.id) || 0}
onpick={(/** @type {any} */ s) => (selected = s)}
/>
{/each}
</div>
</section>
{/each}
{#if recentWithStickers.length > 0}
<section class="recent-section">
<h2>Letzte Sticker</h2>
<div class="recent-list">
{#each recentWithStickers as completion}
{#each recentWithStickers as completion (completion._id)}
{@const sticker = getStickerById(completion.stickerId)}
{#if sticker}
<div class="recent-item">
@@ -159,8 +199,15 @@
</section>
{/if}
{#if selectedSticker}
<StickerPopup sticker={selectedSticker} title={selectedSticker.name} buttonText="Schließen" bounce={false} onclose={() => selectedSticker = null} />
{#if selected}
{@const meta = info.get(selected.id)}
<VinylStickerCard
sticker={selected}
count={counts.get(selected.id) || 0}
firstEarnedLabel={meta?.first || ''}
sourceTask={meta?.task || ''}
onclose={() => (selected = null)}
/>
{/if}
<div class="danger-zone">
@@ -173,7 +220,7 @@
<style>
.rewards-page {
max-width: 900px;
max-width: 1000px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
@@ -209,102 +256,102 @@
transition: width 500ms ease;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.75rem;
margin: 1.5rem 0 0.75rem;
}
/* Sticker grid */
.sticker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
/* sticker album pages */
.page {
margin-bottom: 1.25rem;
padding: 1rem 1rem 1.25rem;
border-radius: var(--radius-lg);
background-color: #f3ecd9;
background-image: radial-gradient(rgba(120, 100, 70, 0.16) 1px, transparent 1.4px);
background-size: 18px 18px;
border: 1px solid #e4d9be;
box-shadow: var(--shadow-sm), inset 0 0 40px rgba(150, 130, 90, 0.08);
}
.sticker-card {
.page-head {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0.5rem;
border-radius: 14px;
border: 1px solid var(--color-border, #e8e4dd);
background: var(--color-bg-primary, white);
transition: transform 150ms, box-shadow 150ms;
align-items: baseline;
justify-content: space-between;
margin: 0 0 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 2px dashed #cdbf9d;
}
.sticker-card.owned {
border-color: var(--rarity-color);
border-width: 1.5px;
cursor: pointer;
.page-head h3 {
margin: 0;
font-family: 'Fredoka', Helvetica, sans-serif;
font-weight: 600;
font-size: 1.1rem;
color: #5a4a2c;
}
.sticker-card.owned:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.sticker-card.locked {
opacity: 0.4;
filter: grayscale(0.8);
}
.sticker-visual {
position: relative;
width: 60px;
height: 60px;
display: flex;
.ph-title { display: flex; align-items: center; gap: 0.45rem; }
.info-btn {
width: 18px;
height: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 0.4rem;
}
.owned .sticker-visual {
background: radial-gradient(circle, var(--rarity-color) 0%, transparent 70%);
border: 1.5px solid #b9a877;
background: transparent;
color: #8a7747;
border-radius: 50%;
opacity: 0.95;
}
.sticker-img {
width: 52px;
height: 52px;
object-fit: contain;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.15));
}
.sticker-unknown {
font-size: 1.6rem;
font-family: Georgia, serif;
font-style: italic;
font-size: 0.72rem;
font-weight: 700;
color: var(--color-text-secondary, #ccc);
opacity: 0.4;
line-height: 1;
cursor: pointer;
transition: all 120ms;
}
.sticker-count {
position: absolute;
bottom: -2px;
right: -2px;
background: var(--nord10);
color: white;
font-size: 0.65rem;
.info-btn:hover, .info-btn.open {
background: #8a7747;
color: #f3ecd9;
border-color: #8a7747;
}
.page-count {
font-family: 'Fredoka', Helvetica, sans-serif;
font-weight: 700;
padding: 0.1rem 0.35rem;
border-radius: 100px;
line-height: 1.2;
font-size: 0.8rem;
color: #8a7747;
}
.sticker-info {
text-align: center;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.sticker-name {
.earn-info {
margin: 0 0 0.7rem;
padding: 0.5rem 0.7rem;
font-size: 0.78rem;
line-height: 1.5;
color: #5a4a2c;
background: rgba(255, 255, 255, 0.55);
border: 1px dashed #cdbf9d;
border-radius: var(--radius-md);
}
.earn-info strong { color: #5a4a2c; }
.tags { display: inline-flex; flex-wrap: wrap; gap: 0.25rem; vertical-align: middle; }
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.08rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
color: #6a5a3a;
background: color-mix(in srgb, var(--nord14) 22%, #fff);
border: 1px solid color-mix(in srgb, var(--nord14) 45%, transparent);
border-radius: var(--radius-pill);
}
.sticker-rarity {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
.sheet {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 0.4rem 0.2rem;
}
.sticker-desc {
font-size: 0.68rem;
color: var(--color-text-secondary, #999);
/* the album sheet is a physical page — stays warm in dark mode */
:global(:root[data-theme='dark']) .page,
:global(:root:not([data-theme='light'])) .page {
background-color: #ece3cb;
}
/* Recent section */
@@ -371,13 +418,6 @@
/* Dark mode */
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .sticker-card {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root:not([data-theme="light"])) .sticker-card.owned {
border-color: var(--rarity-color);
}
:global(:root:not([data-theme="light"])) .recent-item {
background: var(--nord1);
border-color: var(--nord2);
@@ -386,13 +426,6 @@
background: var(--nord2);
}
}
:global(:root[data-theme="dark"]) .sticker-card {
background: var(--nord1);
border-color: var(--nord2);
}
:global(:root[data-theme="dark"]) .sticker-card.owned {
border-color: var(--rarity-color);
}
:global(:root[data-theme="dark"]) .recent-item {
background: var(--nord1);
border-color: var(--nord2);
@@ -426,13 +459,4 @@
border-color: var(--nord11);
background: rgba(191, 97, 106, 0.06);
}
@media (max-width: 600px) {
.sticker-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.5rem;
}
.sticker-card { padding: 0.7rem 0.3rem; }
h1 { font-size: 1.3rem; }
}
</style>
+28 -14
View File
@@ -1,4 +1,5 @@
import path from 'path';
import { writeFile, mkdir } from 'fs/promises';
import sharp from 'sharp';
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
import { validateImageFile } from '$utils/imageValidation';
@@ -132,21 +133,34 @@ 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 fullDir = path.join(imageDir, 'rezepte', 'full');
const thumbDir = path.join(imageDir, 'rezepte', 'thumb');
// fs.writeFile (unlike sharp's toFile) does not create parent dirs, so ensure
// both target directories exist before writing.
await mkdir(fullDir, { recursive: true });
await mkdir(thumbDir, { recursive: true });
const fullHashedPath = path.join(imageDir, 'rezepte', 'full', hashedFilename);
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename);
const fullHashedPath = path.join(fullDir, hashedFilename);
const fullUnhashedPath = path.join(fullDir, 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
@@ -158,8 +172,8 @@ export async function processAndSaveRecipeImage(
.toBuffer();
console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes');
const thumbHashedPath = path.join(imageDir, 'rezepte', 'thumb', hashedFilename);
const thumbUnhashedPath = path.join(imageDir, 'rezepte', 'thumb', unhashedFilename);
const thumbHashedPath = path.join(thumbDir, hashedFilename);
const thumbUnhashedPath = path.join(thumbDir, unhashedFilename);
console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath });
await sharp(thumbBuffer).toFile(thumbHashedPath);
+14
View File
@@ -72,6 +72,20 @@ export interface RecipeFormData {
translationMetadata?: TranslationMetadata;
}
/**
* Build a plain object of form values that is safe to return from a SvelteKit
* action (e.g. inside `fail(...)`). Drops File entries such as `recipe_image`,
* which devalue cannot serialize and which would otherwise crash the action
* response with a 500 ("Cannot stringify arbitrary non-POJOs").
*/
export function serializableFormValues(formData: FormData): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
if (typeof value === 'string') out[key] = value;
}
return out;
}
/**
* Extracts recipe data from FormData
* Handles both simple fields and complex JSON-encoded nested structures
-416458
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -19,7 +19,12 @@ const config = {
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
precompress: true // Enable brotli and gzip compression
// Precompression is handled by scripts/precompress.ts in postbuild.
// The adapter's own precompress is single-threaded and brotli-q11s every
// file in build/client — including ~90 MB of already-compressed media and
// 20 MB+ text blobs — adding minutes to the build for no gain. Our step is
// parallel, skips binaries, and tunes brotli quality by size.
precompress: false
}),
prerender: {
// The only intentionally-static pages are /hikes (prerender=true) and