Compare commits
6 Commits
fb54f6907f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
467f9a4e71
|
|||
|
8bd794bccb
|
|||
|
f52d6b4d4b
|
|||
|
9b5cfe5e49
|
|||
|
9fe9d95e36
|
|||
|
cd7912fa8f
|
@@ -7,6 +7,7 @@ node_modules
|
|||||||
/package
|
/package
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
.env_*
|
||||||
!.env.example
|
!.env.example
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|||||||
@@ -173,7 +173,6 @@ Generated: 2025-11-18
|
|||||||
- `EditButton.svelte` - Edit button (floating)
|
- `EditButton.svelte` - Edit button (floating)
|
||||||
- `FavoriteButton.svelte` - Toggle favorite
|
- `FavoriteButton.svelte` - Toggle favorite
|
||||||
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
|
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
|
||||||
- `CardAdd.svelte` - Add recipe card placeholder
|
|
||||||
- `FormSection.svelte` - Styled form section wrapper
|
- `FormSection.svelte` - Styled form section wrapper
|
||||||
- `Header.svelte` - Page header
|
- `Header.svelte` - Page header
|
||||||
- `UserHeader.svelte` - User-specific header
|
- `UserHeader.svelte` - User-specific header
|
||||||
@@ -190,7 +189,6 @@ Generated: 2025-11-18
|
|||||||
|
|
||||||
#### Recipe-Specific Components
|
#### Recipe-Specific Components
|
||||||
- `Recipes.svelte` - Recipe list display
|
- `Recipes.svelte` - Recipe list display
|
||||||
- `RecipeEditor.svelte` - Recipe editing form
|
|
||||||
- `RecipeNote.svelte` - Recipe notes display
|
- `RecipeNote.svelte` - Recipe notes display
|
||||||
- `EditRecipe.svelte` - Edit recipe modal
|
- `EditRecipe.svelte` - Edit recipe modal
|
||||||
- `EditRecipeNote.svelte` - Edit recipe notes
|
- `EditRecipeNote.svelte` - Edit recipe notes
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.94.1",
|
"version": "1.96.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"dev": "vite dev",
|
"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",
|
"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",
|
"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",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
|||||||
+19
-1
@@ -51,7 +51,25 @@ echo " node $local_node (match)"
|
|||||||
echo ":: Installing deps (frozen lockfile)"
|
echo ":: Installing deps (frozen lockfile)"
|
||||||
pnpm install --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
|
pnpm build
|
||||||
|
|
||||||
if [[ ! -d build ]]; then
|
if [[ ! -d build ]]; then
|
||||||
|
|||||||
@@ -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; // 1–4 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
@@ -467,10 +467,11 @@ a:focus-visible {
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
HIKES TRANSITIONS
|
HIKES TRANSITIONS
|
||||||
Cards + filter fly in/out vertically, clicked card morphs into the hero
|
Cards + filter fly in/out vertically, clicked card morphs into the hero
|
||||||
map (cross-fade between thumbnail and map), photo strip slides in from
|
map (cross-fade between thumbnail and map), and the whole below-map panel
|
||||||
the right. Page chrome under the hero cross-fades so nothing snaps in
|
(an opaque sheet) slides up from the bottom. Page chrome under the hero
|
||||||
at transition end. Lives in app.css (not the page component) so the
|
cross-fades so nothing snaps in at transition end. Lives in app.css (not
|
||||||
rules are still loaded on the OLD side of a nav AWAY from /hikes.
|
the page component) so the rules are still loaded on the OLD side of a
|
||||||
|
nav AWAY from /hikes.
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@keyframes hikes-fly-up {
|
@keyframes hikes-fly-up {
|
||||||
@@ -489,15 +490,6 @@ a:focus-visible {
|
|||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
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):
|
/* 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
|
* kill UA's default fade, switch blend mode so the custom fly animation
|
||||||
* shows clean motion against the rest of the page. */
|
* 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;
|
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,
|
/* Everything below the hero map on a detail page — stage nav, photo strip,
|
||||||
* and slides back out whenever the detail page is left for any other
|
* metrics, tags, elevation chart, scroll area, meta footer — slides up from
|
||||||
* route (back to /hikes, off to /, /hikes/route-builder, …). Both exit
|
* the bottom on enter and back down on any exit, as one panel. The wrapper
|
||||||
* scopes (vt-enter-hikes for the back-nav case, vt-exit-hike-detail for
|
* carries `view-transition-name: hike-below-map` and an opaque background, so
|
||||||
* everywhere else) trigger the same animation. */
|
* the whole sheet (background included) moves; the hero map morphs separately
|
||||||
html.vt-enter-hike-detail::view-transition-new(hike-strip):only-child {
|
* above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
|
||||||
animation: hike-strip-in-right 600ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
|
||||||
}
|
|
||||||
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 {
|
|
||||||
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
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-enter-hikes::view-transition-old(hike-below-map):only-child,
|
||||||
html.vt-exit-hike-detail::view-transition-old(hike-below-strip):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;
|
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,434 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
|
|
||||||
import Cross from '$lib/assets/icons/Cross.svelte'
|
|
||||||
import { toast } from '$lib/js/toast.svelte'
|
|
||||||
import "$lib/css/shake.css"
|
|
||||||
import "$lib/css/icon.css"
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
|
|
||||||
let {
|
|
||||||
card_data = $bindable(),
|
|
||||||
image_preview_url = $bindable(''),
|
|
||||||
selected_image_file = $bindable<File | null>(null),
|
|
||||||
short_name = ''
|
|
||||||
}: {
|
|
||||||
card_data: any,
|
|
||||||
image_preview_url: string,
|
|
||||||
selected_image_file: File | null,
|
|
||||||
short_name: string
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
// Constants for validation
|
|
||||||
const ALLOWED_MIME_TYPES = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
||||||
|
|
||||||
// Handle file selection via onchange event
|
|
||||||
function handleFileSelect(event: Event) {
|
|
||||||
const input = event.currentTarget as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate MIME type
|
|
||||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
|
||||||
toast.error('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
toast.error(`File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`);
|
|
||||||
input.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old preview URL if exists
|
|
||||||
if (image_preview_url && image_preview_url.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(image_preview_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create preview and store file
|
|
||||||
image_preview_url = URL.createObjectURL(file);
|
|
||||||
selected_image_file = file;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if initial image_preview_url redirects to placeholder
|
|
||||||
onMount(() => {
|
|
||||||
if (image_preview_url && !image_preview_url.startsWith('blob:')) {
|
|
||||||
const img = new Image();
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
// Check if this is the placeholder image (150x150)
|
|
||||||
if (img.naturalWidth === 150 && img.naturalHeight === 150) {
|
|
||||||
image_preview_url = ""
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
image_preview_url = ""
|
|
||||||
};
|
|
||||||
|
|
||||||
img.src = image_preview_url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize tags if needed
|
|
||||||
if (!card_data.tags) {
|
|
||||||
card_data.tags = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag management
|
|
||||||
let new_tag = $state("");
|
|
||||||
|
|
||||||
// Reference to file input for clearing
|
|
||||||
let fileInput: HTMLInputElement;
|
|
||||||
|
|
||||||
function remove_selected_images() {
|
|
||||||
if (image_preview_url && image_preview_url.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(image_preview_url);
|
|
||||||
}
|
|
||||||
image_preview_url = "";
|
|
||||||
selected_image_file = null;
|
|
||||||
// Reset the file input
|
|
||||||
if (fileInput) {
|
|
||||||
fileInput.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function add_to_tags() {
|
|
||||||
if (new_tag && !card_data.tags.includes(new_tag)) {
|
|
||||||
card_data.tags = [...card_data.tags, new_tag];
|
|
||||||
}
|
|
||||||
new_tag = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove_from_tags(tag: string) {
|
|
||||||
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
function add_on_enter(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
add_to_tags();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function remove_on_enter(event: KeyboardEvent, tag: string) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
.card{
|
|
||||||
position: relative;
|
|
||||||
margin-inline: auto;
|
|
||||||
--card-width: 300px;
|
|
||||||
text-decoration: none;
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: var(--card-width);
|
|
||||||
aspect-ratio: 4/7;
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
background-size: contain;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: end;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
background-color: var(--blue);
|
|
||||||
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.img_label{
|
|
||||||
position :absolute;
|
|
||||||
z-index: 1;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
border-radius: 20px 20px 0 0 ;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.img_label_wrapper:hover{
|
|
||||||
background-color: var(--red);
|
|
||||||
box-shadow: 0 2em 1em 0.5em rgba(0,0,0,0.3);
|
|
||||||
transform:scale(1.02, 1.02);
|
|
||||||
}
|
|
||||||
.img_label_wrapper{
|
|
||||||
position: absolute;
|
|
||||||
height: 50%;
|
|
||||||
width: 100%;
|
|
||||||
top:0;
|
|
||||||
left: 0;
|
|
||||||
border-radius: 20px 20px 0 0;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.img_label_wrapper:hover .delete{
|
|
||||||
opacity: 100%;
|
|
||||||
}
|
|
||||||
.img_label svg{
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
fill: white;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.delete{
|
|
||||||
cursor: pointer;
|
|
||||||
all: unset;
|
|
||||||
position: absolute;
|
|
||||||
top:2rem;
|
|
||||||
left: 2rem;
|
|
||||||
opacity: 0%;
|
|
||||||
z-index: 4;
|
|
||||||
transition: var(--transition-normal);
|
|
||||||
}
|
|
||||||
.delete:hover{
|
|
||||||
transform: scale(1.2, 1.2);
|
|
||||||
}
|
|
||||||
.upload{
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.img_label:hover .upload{
|
|
||||||
transform: scale(1.2, 1.2);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
#img_picker{
|
|
||||||
display: none;
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
position:absolute;
|
|
||||||
}
|
|
||||||
input{
|
|
||||||
all: unset;
|
|
||||||
}
|
|
||||||
input::placeholder{
|
|
||||||
all:unset;
|
|
||||||
}
|
|
||||||
.card .icon{
|
|
||||||
z-index: 3;
|
|
||||||
box-sizing: border-box;
|
|
||||||
text-decoration: unset;
|
|
||||||
text-align:center;
|
|
||||||
width: 2.6rem;
|
|
||||||
aspect-ratio: 1/1;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
position: absolute;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
top:-0.5em;
|
|
||||||
right:-0.5em;
|
|
||||||
padding: 0.25em;
|
|
||||||
background-color: var(--nord6);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
.card .icon:hover,
|
|
||||||
.card .icon:focus-visible
|
|
||||||
{
|
|
||||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
|
||||||
transform:scale(1.2, 1.2)
|
|
||||||
}
|
|
||||||
.card:hover,
|
|
||||||
.card:focus-within{
|
|
||||||
transform: scale(1.02,1.02);
|
|
||||||
box-shadow: 0.2em 0.2em 2em 1em rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card img{
|
|
||||||
height: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border-top-left-radius: inherit;
|
|
||||||
border-top-right-radius: inherit;
|
|
||||||
}
|
|
||||||
.card .title {
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding-top: 0.5em;
|
|
||||||
height: 50%;
|
|
||||||
width: 100% ;
|
|
||||||
border-bottom-left-radius: inherit;
|
|
||||||
border-bottom-right-radius: inherit;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
}
|
|
||||||
.card .name{
|
|
||||||
all: unset;
|
|
||||||
width:100%;
|
|
||||||
font-size: 2em;
|
|
||||||
color: white;
|
|
||||||
padding-inline: 0.5em;
|
|
||||||
padding-block: 0.2em;
|
|
||||||
}
|
|
||||||
.card .name:hover{
|
|
||||||
color:var(--nord0);
|
|
||||||
}
|
|
||||||
.card .description{
|
|
||||||
box-sizing:border-box;
|
|
||||||
border: 2px solid var(--nord5);
|
|
||||||
border-radius: 30px;
|
|
||||||
padding-inline: 1em;
|
|
||||||
padding-block: 0.5em;
|
|
||||||
margin-inline: 1em;
|
|
||||||
margin-top: 0;
|
|
||||||
color: var(--nord4);
|
|
||||||
width: calc(300px - 2em); /*??*/
|
|
||||||
}
|
|
||||||
.card .description:hover{
|
|
||||||
color: var(--nord0);
|
|
||||||
border: 2px solid var(--nord0);
|
|
||||||
}
|
|
||||||
.card .tags{
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap-reverse;
|
|
||||||
overflow: hidden;
|
|
||||||
column-gap: 0.25em;
|
|
||||||
padding-inline: 0.5em;
|
|
||||||
padding-top: 0.25em;
|
|
||||||
margin-bottom:0.5em;
|
|
||||||
flex-grow: 0;
|
|
||||||
}
|
|
||||||
.card .tag{
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: unset;
|
|
||||||
background-color: var(--nord4);
|
|
||||||
color: var(--nord0);
|
|
||||||
border-radius: 100px;
|
|
||||||
padding-inline: 1em;
|
|
||||||
line-height: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
box-shadow: 0.2em 0.2em 0.2em 0.05em rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
.card .tag:hover,
|
|
||||||
.card .tag:focus-visible,
|
|
||||||
.card .tag:focus-within
|
|
||||||
{
|
|
||||||
transform: scale(1.04, 1.04);
|
|
||||||
background-color: var(--nord8);
|
|
||||||
box-shadow: 0.2em 0.2em 0.2em 0.1em rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card .title .category{
|
|
||||||
z-index: 2;
|
|
||||||
position: absolute;
|
|
||||||
box-shadow: 0em 0em 1em 0.1em rgba(0, 0, 0, 0.6);
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--nord6);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
top: -0.8em;
|
|
||||||
left: -0.5em;
|
|
||||||
width: 10rem;
|
|
||||||
background-color: var(--nord0);
|
|
||||||
padding-inline: 1em;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
transition: var(--transition-fast);
|
|
||||||
|
|
||||||
}
|
|
||||||
.card .title .category:hover,
|
|
||||||
.card .title .category:focus-within
|
|
||||||
{
|
|
||||||
box-shadow: -0.2em 0.2em 1em 0.1em rgba(0, 0, 0, 0.6);
|
|
||||||
background-color: var(--nord3);
|
|
||||||
transform: scale(1.05, 1.05)
|
|
||||||
}
|
|
||||||
.card:hover .icon,
|
|
||||||
.card:focus-visible .icon
|
|
||||||
{
|
|
||||||
animation: shake 0.6s
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shake{
|
|
||||||
0%{
|
|
||||||
transform: rotate(0)
|
|
||||||
scale(1,1);
|
|
||||||
}
|
|
||||||
25%{
|
|
||||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
|
||||||
transform: rotate(30deg)
|
|
||||||
scale(1.2,1.2)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
50%{
|
|
||||||
|
|
||||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
|
||||||
transform: rotate(-30deg)
|
|
||||||
scale(1.2,1.2);
|
|
||||||
}
|
|
||||||
74%{
|
|
||||||
|
|
||||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
|
||||||
transform: rotate(30deg)
|
|
||||||
scale(1.2, 1.2);
|
|
||||||
}
|
|
||||||
100%{
|
|
||||||
transform: rotate(0)
|
|
||||||
scale(1,1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input_wrapper{
|
|
||||||
position: relative;
|
|
||||||
padding-left: 3rem;
|
|
||||||
padding-left: 40rem;
|
|
||||||
}
|
|
||||||
.input_wrapper > input{
|
|
||||||
margin-left: 1ch;
|
|
||||||
}
|
|
||||||
.input{
|
|
||||||
position:absolute;
|
|
||||||
top: -.1ch;
|
|
||||||
left: 0.6ch;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
}
|
|
||||||
.tag_input{
|
|
||||||
width: 12ch;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
<div class=card>
|
|
||||||
|
|
||||||
<input class=icon placeholder=🥫 bind:value={card_data.icon}/>
|
|
||||||
{#if image_preview_url}
|
|
||||||
<!-- svelte-ignore a11y_missing_attribute -->
|
|
||||||
<img src={image_preview_url} class=img_preview width=300px height=300px />
|
|
||||||
{/if}
|
|
||||||
<div class=img_label_wrapper>
|
|
||||||
{#if image_preview_url}
|
|
||||||
<button class=delete onclick={remove_selected_images}>
|
|
||||||
<Cross fill=white style="width:2rem;height:2rem;"></Cross>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
|
|
||||||
<label class=img_label for=img_picker>
|
|
||||||
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<input type="file" id="img_picker" accept="image/webp,image/jpeg,image/jpg,image/png" onchange={handleFileSelect} bind:this={fileInput}>
|
|
||||||
<div class=title>
|
|
||||||
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
|
|
||||||
<div>
|
|
||||||
<input class=name placeholder=Name... bind:value={card_data.name}/>
|
|
||||||
<p contenteditable class=description placeholder=Kurzbeschreibung... bind:innerText={card_data.description}></p>
|
|
||||||
</div>
|
|
||||||
<div class=tags>
|
|
||||||
{#each card_data.tags as tag (tag)}
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
||||||
<div class="tag" role="button" tabindex="0" onkeydown={(event) => remove_on_enter(event, tag)} onclick={() => remove_from_tags(tag)} aria-label="Tag {tag} entfernen">{tag}</div>
|
|
||||||
{/each}
|
|
||||||
<div class="tag input_wrapper"><span class=input>+</span><input class="tag_input" type="text" onkeydown={add_on_enter} onfocusout={add_to_tags} size="1" bind:value={new_tag} placeholder=Stichwort...></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Cross from '$lib/assets/icons/Cross.svelte';
|
import Cross from '$lib/assets/icons/Cross.svelte';
|
||||||
|
import ImageEditor from '$lib/components/recipes/ImageEditor.svelte';
|
||||||
import { toast } from '$lib/js/toast.svelte';
|
import { toast } from '$lib/js/toast.svelte';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -53,9 +54,34 @@
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
openEditor(file);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo editor (crop / scale / webp quality) state
|
||||||
|
let editorFile = $state<File | null>(null);
|
||||||
|
let editorOpen = $state(false);
|
||||||
|
|
||||||
|
function openEditor(file: File) {
|
||||||
|
editorFile = file;
|
||||||
|
editorOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
editorOpen = false;
|
||||||
|
editorFile = null;
|
||||||
|
if (fileInput) fileInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditorApply(file: File, url: string) {
|
||||||
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
|
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
|
||||||
image_preview_url = URL.createObjectURL(file);
|
|
||||||
selected_image_file = file;
|
selected_image_file = file;
|
||||||
|
image_preview_url = url;
|
||||||
|
closeEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCurrentImage() {
|
||||||
|
if (selected_image_file) openEditor(selected_image_file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSelectedImage() {
|
function clearSelectedImage() {
|
||||||
@@ -129,15 +155,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{#if selected_image_file}
|
{#if selected_image_file}
|
||||||
<button
|
<div class="img-controls">
|
||||||
type="button"
|
<button
|
||||||
class="clear-img"
|
type="button"
|
||||||
onclick={clearSelectedImage}
|
class="img-btn"
|
||||||
title="Auswahl verwerfen"
|
onclick={editCurrentImage}
|
||||||
aria-label="Auswahl verwerfen"
|
title="Bild bearbeiten"
|
||||||
>
|
aria-label="Bild bearbeiten"
|
||||||
<Cross fill="white" width="1.25rem" height="1.25rem" />
|
>
|
||||||
</button>
|
<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}
|
{/if}
|
||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
@@ -215,6 +256,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if editorOpen && editorFile}
|
||||||
|
<ImageEditor file={editorFile} onApply={handleEditorApply} onCancel={closeEditor} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.section {
|
.section {
|
||||||
--scale: 0.3;
|
--scale: 0.3;
|
||||||
@@ -312,10 +357,18 @@
|
|||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear-img {
|
/* Edit / remove controls — top-right of the image, offset below the fixed
|
||||||
|
site header (height 3rem, top max(12px, safe-area+4px)) so the nav never
|
||||||
|
obstructs them. */
|
||||||
|
.img-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(1rem + env(safe-area-inset-top, 0px));
|
top: calc(max(12px, env(safe-area-inset-top, 0px) + 4px) + 3rem + 1rem);
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.img-btn {
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
border: none;
|
border: none;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
@@ -324,17 +377,26 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 5;
|
|
||||||
transition:
|
transition:
|
||||||
transform 150ms ease,
|
transform 150ms ease,
|
||||||
background 150ms ease;
|
background 150ms ease;
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.clear-img:hover,
|
.img-btn svg {
|
||||||
.clear-img:focus-visible {
|
width: 1.15rem;
|
||||||
background: var(--red);
|
height: 1.15rem;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
.img-btn:hover,
|
||||||
|
.img-btn:focus-visible {
|
||||||
|
background: var(--color-primary);
|
||||||
transform: scale(1.08);
|
transform: scale(1.08);
|
||||||
}
|
}
|
||||||
|
.img-btn.danger:hover,
|
||||||
|
.img-btn.danger:focus-visible {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
.file-input {
|
.file-input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -0,0 +1,797 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
loadBitmap,
|
||||||
|
renderToBlob,
|
||||||
|
fitWithin,
|
||||||
|
blobToFile,
|
||||||
|
formatBytes,
|
||||||
|
type CropRect
|
||||||
|
} from '$lib/js/imageEdit';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
file: File;
|
||||||
|
shortName?: string;
|
||||||
|
onApply: (file: File, url: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { file, shortName = '', onApply, onCancel }: Props = $props();
|
||||||
|
|
||||||
|
const MIN_CROP = 24; // minimum crop edge, source px
|
||||||
|
|
||||||
|
const RATIOS = [
|
||||||
|
{ key: 'free', label: 'Frei' },
|
||||||
|
{ key: 'orig', label: 'Original' },
|
||||||
|
{ key: '1:1', label: '1:1', value: 1 },
|
||||||
|
{ key: '4:3', label: '4:3', value: 4 / 3 },
|
||||||
|
{ key: '3:2', label: '3:2', value: 3 / 2 },
|
||||||
|
{ key: '16:9', label: '16:9', value: 16 / 9 }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const RES_PRESETS = [1000, 1500, 2000, 0]; // 0 = Original
|
||||||
|
|
||||||
|
let bitmap = $state<ImageBitmap | null>(null);
|
||||||
|
let imgW = $state(0);
|
||||||
|
let imgH = $state(0);
|
||||||
|
let loadError = $state('');
|
||||||
|
|
||||||
|
let crop = $state<CropRect>({ x: 0, y: 0, w: 0, h: 0 });
|
||||||
|
let ratioMode = $state<string>('free');
|
||||||
|
let maxRes = $state(2000);
|
||||||
|
let quality = $state(92);
|
||||||
|
|
||||||
|
// Live-encode output
|
||||||
|
let outBlob = $state<Blob | null>(null);
|
||||||
|
let outUrl = $state('');
|
||||||
|
let outW = $state(0);
|
||||||
|
let outH = $state(0);
|
||||||
|
let encoding = $state(false);
|
||||||
|
|
||||||
|
// Stage measurement
|
||||||
|
let stageW = $state(0);
|
||||||
|
let stageH = $state(0);
|
||||||
|
let stageCanvas = $state<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
const activeRatio = $derived.by(() => {
|
||||||
|
const r = RATIOS.find((x) => x.key === ratioMode);
|
||||||
|
if (!r) return null;
|
||||||
|
if (r.key === 'orig') return imgH ? imgW / imgH : null;
|
||||||
|
return 'value' in r ? r.value : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit the source image into the available stage area (display pixels).
|
||||||
|
const displayScale = $derived.by(() => {
|
||||||
|
if (!imgW || !imgH || !stageW || !stageH) return 1;
|
||||||
|
const availW = Math.max(1, stageW - 24);
|
||||||
|
const availH = Math.max(1, stageH - 24);
|
||||||
|
return Math.min(availW / imgW, availH / imgH);
|
||||||
|
});
|
||||||
|
const dispW = $derived(Math.round(imgW * displayScale));
|
||||||
|
const dispH = $derived(Math.round(imgH * displayScale));
|
||||||
|
|
||||||
|
function clamp(v: number, lo: number, hi: number) {
|
||||||
|
return Math.max(lo, Math.min(hi, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const bm = await loadBitmap(file);
|
||||||
|
if (cancelled) {
|
||||||
|
bm.close?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bitmap = bm;
|
||||||
|
imgW = bm.width;
|
||||||
|
imgH = bm.height;
|
||||||
|
crop = { x: 0, y: 0, w: bm.width, h: bm.height };
|
||||||
|
} catch {
|
||||||
|
loadError = 'Bild konnte nicht geladen werden.';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw the source onto the display canvas whenever it or the layout changes.
|
||||||
|
$effect(() => {
|
||||||
|
const cv = stageCanvas;
|
||||||
|
const bm = bitmap;
|
||||||
|
const w = dispW;
|
||||||
|
const h = dispH;
|
||||||
|
if (!cv || !bm || w <= 0 || h <= 0) return;
|
||||||
|
cv.width = w;
|
||||||
|
cv.height = h;
|
||||||
|
const ctx = cv.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.drawImage(bm, 0, 0, imgW, imgH, 0, 0, w, h);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounced live encode — runs whenever crop / resolution / quality change.
|
||||||
|
let encodeToken = 0;
|
||||||
|
$effect(() => {
|
||||||
|
const bm = bitmap;
|
||||||
|
if (!bm) return;
|
||||||
|
const c = { ...crop };
|
||||||
|
const mr = maxRes;
|
||||||
|
const q = quality;
|
||||||
|
const token = ++encodeToken;
|
||||||
|
encoding = true;
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const blob = await renderToBlob(bm, c, mr, q);
|
||||||
|
if (token !== encodeToken) return;
|
||||||
|
const size = fitWithin(c.w, c.h, mr);
|
||||||
|
if (outUrl) URL.revokeObjectURL(outUrl);
|
||||||
|
outBlob = blob;
|
||||||
|
outUrl = URL.createObjectURL(blob);
|
||||||
|
outW = size.w;
|
||||||
|
outH = size.h;
|
||||||
|
} catch {
|
||||||
|
/* transient encode failure — next change retries */
|
||||||
|
} finally {
|
||||||
|
if (token === encodeToken) encoding = false;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
if (outUrl) URL.revokeObjectURL(outUrl);
|
||||||
|
bitmap?.close?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Crop drag handling ---
|
||||||
|
type Drag = { handle: string; hx: number; hy: number; px: number; py: number; start: CropRect };
|
||||||
|
let drag: Drag | null = null;
|
||||||
|
|
||||||
|
function startDrag(e: PointerEvent, handle: string, hx: number, hy: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||||
|
drag = { handle, hx, hy, px: e.clientX, py: e.clientY, start: { ...crop } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!drag || displayScale === 0) return;
|
||||||
|
const ddx = (e.clientX - drag.px) / displayScale;
|
||||||
|
const ddy = (e.clientY - drag.py) / displayScale;
|
||||||
|
const s = drag.start;
|
||||||
|
|
||||||
|
if (drag.handle === 'move') {
|
||||||
|
crop = {
|
||||||
|
x: clamp(s.x + ddx, 0, imgW - s.w),
|
||||||
|
y: clamp(s.y + ddy, 0, imgH - s.h),
|
||||||
|
w: s.w,
|
||||||
|
h: s.h
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = s.x;
|
||||||
|
let top = s.y;
|
||||||
|
let right = s.x + s.w;
|
||||||
|
let bottom = s.y + s.h;
|
||||||
|
if (drag.hx === 1) right = s.x + s.w + ddx;
|
||||||
|
else if (drag.hx === -1) left = s.x + ddx;
|
||||||
|
if (drag.hy === 1) bottom = s.y + s.h + ddy;
|
||||||
|
else if (drag.hy === -1) top = s.y + ddy;
|
||||||
|
|
||||||
|
const r = activeRatio;
|
||||||
|
if (r) {
|
||||||
|
if (drag.hx !== 0 && drag.hy !== 0) {
|
||||||
|
const nw = Math.max(MIN_CROP, right - left);
|
||||||
|
const nh = nw / r;
|
||||||
|
if (drag.hy === 1) bottom = top + nh;
|
||||||
|
else top = bottom - nh;
|
||||||
|
} else if (drag.hx !== 0) {
|
||||||
|
const cy = s.y + s.h / 2;
|
||||||
|
const nh = Math.max(MIN_CROP, right - left) / r;
|
||||||
|
top = cy - nh / 2;
|
||||||
|
bottom = cy + nh / 2;
|
||||||
|
} else if (drag.hy !== 0) {
|
||||||
|
const cx = s.x + s.w / 2;
|
||||||
|
const nw = Math.max(MIN_CROP, bottom - top) * r;
|
||||||
|
left = cx - nw / 2;
|
||||||
|
right = cx + nw / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
left = Math.max(0, left);
|
||||||
|
top = Math.max(0, top);
|
||||||
|
right = Math.min(imgW, right);
|
||||||
|
bottom = Math.min(imgH, bottom);
|
||||||
|
if (right - left < MIN_CROP) {
|
||||||
|
if (drag.hx === -1) left = right - MIN_CROP;
|
||||||
|
else right = left + MIN_CROP;
|
||||||
|
}
|
||||||
|
if (bottom - top < MIN_CROP) {
|
||||||
|
if (drag.hy === -1) top = bottom - MIN_CROP;
|
||||||
|
else bottom = top + MIN_CROP;
|
||||||
|
}
|
||||||
|
left = Math.max(0, left);
|
||||||
|
top = Math.max(0, top);
|
||||||
|
right = Math.min(imgW, right);
|
||||||
|
bottom = Math.min(imgH, bottom);
|
||||||
|
|
||||||
|
crop = { x: left, y: top, w: right - left, h: bottom - top };
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
// Pointer capture is released implicitly on pointerup.
|
||||||
|
drag = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRatio(key: string) {
|
||||||
|
ratioMode = key;
|
||||||
|
const r = RATIOS.find((x) => x.key === key);
|
||||||
|
const value = r && r.key === 'orig' ? imgW / imgH : r && 'value' in r ? r.value : null;
|
||||||
|
if (!value) return; // 'free' keeps the current crop
|
||||||
|
// Fit a centred rect of this ratio inside the current crop.
|
||||||
|
const cx = crop.x + crop.w / 2;
|
||||||
|
const cy = crop.y + crop.h / 2;
|
||||||
|
let nw = crop.w;
|
||||||
|
let nh = nw / value;
|
||||||
|
if (nh > crop.h) {
|
||||||
|
nh = crop.h;
|
||||||
|
nw = nh * value;
|
||||||
|
}
|
||||||
|
nw = Math.min(nw, imgW);
|
||||||
|
nh = Math.min(nh, imgH);
|
||||||
|
crop = {
|
||||||
|
x: clamp(cx - nw / 2, 0, imgW - nw),
|
||||||
|
y: clamp(cy - nh / 2, 0, imgH - nh),
|
||||||
|
w: nw,
|
||||||
|
h: nh
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCrop() {
|
||||||
|
ratioMode = 'free';
|
||||||
|
crop = { x: 0, y: 0, w: imgW, h: imgH };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
if (!outBlob || !outUrl) return;
|
||||||
|
const url = outUrl;
|
||||||
|
const f = blobToFile(outBlob, shortName);
|
||||||
|
outUrl = ''; // hand the object URL off to the caller; don't revoke it
|
||||||
|
onApply(f, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handles = [
|
||||||
|
{ key: 'nw', hx: -1, hy: -1 },
|
||||||
|
{ key: 'n', hx: 0, hy: -1 },
|
||||||
|
{ key: 'ne', hx: 1, hy: -1 },
|
||||||
|
{ key: 'e', hx: 1, hy: 0 },
|
||||||
|
{ key: 'se', hx: 1, hy: 1 },
|
||||||
|
{ key: 's', hx: 0, hy: 1 },
|
||||||
|
{ key: 'sw', hx: -1, hy: 1 },
|
||||||
|
{ key: 'w', hx: -1, hy: 0 }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Bild bearbeiten"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<button type="button" class="scrim" aria-label="Schliessen" onclick={onCancel}></button>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h2>Bild bearbeiten</h2>
|
||||||
|
<button type="button" class="ghost" onclick={onCancel} aria-label="Abbrechen">✕</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<!-- Stage -->
|
||||||
|
<div class="stage" bind:clientWidth={stageW} bind:clientHeight={stageH}>
|
||||||
|
{#if loadError}
|
||||||
|
<p class="stage-msg">{loadError}</p>
|
||||||
|
{:else if !bitmap}
|
||||||
|
<p class="stage-msg">Lade Bild…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="frame" style:width="{dispW}px" style:height="{dispH}px">
|
||||||
|
<canvas bind:this={stageCanvas}></canvas>
|
||||||
|
<div
|
||||||
|
class="crop"
|
||||||
|
style:left="{crop.x * displayScale}px"
|
||||||
|
style:top="{crop.y * displayScale}px"
|
||||||
|
style:width="{crop.w * displayScale}px"
|
||||||
|
style:height="{crop.h * displayScale}px"
|
||||||
|
role="application"
|
||||||
|
aria-label="Zuschneidebereich verschieben"
|
||||||
|
onpointerdown={(e) => startDrag(e, 'move', 0, 0)}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={endDrag}
|
||||||
|
onpointercancel={endDrag}
|
||||||
|
>
|
||||||
|
<span class="third v1"></span>
|
||||||
|
<span class="third v2"></span>
|
||||||
|
<span class="third h1"></span>
|
||||||
|
<span class="third h2"></span>
|
||||||
|
{#each handles as h (h.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="handle h-{h.key}"
|
||||||
|
aria-label="Ziehpunkt {h.key}"
|
||||||
|
onpointerdown={(e) => startDrag(e, h.key, h.hx, h.hy)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="rail">
|
||||||
|
<div class="preview">
|
||||||
|
<div class="preview-img" class:busy={encoding}>
|
||||||
|
{#if outUrl}
|
||||||
|
<!-- svelte-ignore a11y_missing_attribute -->
|
||||||
|
<img src={outUrl} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<dl class="stats">
|
||||||
|
<div><dt>Auflösung</dt><dd>{outW || '—'} × {outH || '—'}</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Dateigrösse</dt>
|
||||||
|
<dd class="size">{outBlob ? formatBytes(outBlob.size) : '—'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="group">
|
||||||
|
<legend>Seitenverhältnis</legend>
|
||||||
|
<div class="chips">
|
||||||
|
{#each RATIOS as r (r.key)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
class:active={ratioMode === r.key}
|
||||||
|
onclick={() => selectRatio(r.key)}>{r.label}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="group">
|
||||||
|
<legend>Max. Auflösung</legend>
|
||||||
|
<div class="chips">
|
||||||
|
{#each RES_PRESETS as p (p)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
class:active={maxRes === p}
|
||||||
|
onclick={() => (maxRes = p)}>{p === 0 ? 'Original' : p}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<label class="custom">
|
||||||
|
<span>Eigene Kante</span>
|
||||||
|
<input type="number" min="0" step="50" bind:value={maxRes} />
|
||||||
|
<span class="unit">px</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="group">
|
||||||
|
<legend>WebP-Qualität</legend>
|
||||||
|
<div class="quality">
|
||||||
|
<input type="range" min="1" max="100" step="1" bind:value={quality} />
|
||||||
|
<output>{quality}</output>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="button" class="reset" onclick={resetCrop}>Zuschnitt zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="panel-foot">
|
||||||
|
<button type="button" class="btn ghost-btn" onclick={onCancel}>Abbrechen</button>
|
||||||
|
<button type="button" class="btn primary" disabled={!outBlob} onclick={apply}>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: clamp(0px, 2vw, 1.5rem);
|
||||||
|
}
|
||||||
|
.scrim {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 14, 20, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
height: min(760px, 100%);
|
||||||
|
max-height: 100%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.panel-head,
|
||||||
|
.panel-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.panel-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg, 1.2rem);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.panel-foot {
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.ghost:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stage */
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 0;
|
||||||
|
background:
|
||||||
|
repeating-conic-gradient(var(--color-bg-secondary) 0% 25%, transparent 0% 50%) 50% / 24px 24px;
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stage-msg {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.frame {
|
||||||
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.frame canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.crop {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||||
|
cursor: move;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.third {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.third.v1,
|
||||||
|
.third.v2 {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
.third.v1 {
|
||||||
|
left: 33.33%;
|
||||||
|
}
|
||||||
|
.third.v2 {
|
||||||
|
left: 66.66%;
|
||||||
|
}
|
||||||
|
.third.h1,
|
||||||
|
.third.h2 {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.third.h1 {
|
||||||
|
top: 33.33%;
|
||||||
|
}
|
||||||
|
.third.h2 {
|
||||||
|
top: 66.66%;
|
||||||
|
}
|
||||||
|
.handle {
|
||||||
|
all: unset;
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.h-nw {
|
||||||
|
top: -7px;
|
||||||
|
left: -7px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
.h-ne {
|
||||||
|
top: -7px;
|
||||||
|
right: -7px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
.h-se {
|
||||||
|
bottom: -7px;
|
||||||
|
right: -7px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
.h-sw {
|
||||||
|
bottom: -7px;
|
||||||
|
left: -7px;
|
||||||
|
cursor: nesw-resize;
|
||||||
|
}
|
||||||
|
.h-n {
|
||||||
|
top: -7px;
|
||||||
|
left: calc(50% - 7px);
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
.h-s {
|
||||||
|
bottom: -7px;
|
||||||
|
left: calc(50% - 7px);
|
||||||
|
cursor: ns-resize;
|
||||||
|
}
|
||||||
|
.h-e {
|
||||||
|
right: -7px;
|
||||||
|
top: calc(50% - 7px);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
.h-w {
|
||||||
|
left: -7px;
|
||||||
|
top: calc(50% - 7px);
|
||||||
|
cursor: ew-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rail */
|
||||||
|
.rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.1rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.preview-img {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
.preview-img.busy {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.preview-img img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stats dt {
|
||||||
|
font-size: var(--text-sm, 0.8rem);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.stats dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-md, 1rem);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stats dd.size {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.group legend {
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--text-sm, 0.8rem);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm, 0.85rem);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.chip.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
.custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: var(--text-sm, 0.85rem);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.custom input {
|
||||||
|
width: 6ch;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.custom .unit {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.quality {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.quality input[type='range'] {
|
||||||
|
flex: 1;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.quality output {
|
||||||
|
min-width: 2.5ch;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.reset {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm, 0.85rem);
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
.reset:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
.ghost-btn {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.ghost-btn:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
.primary:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
.primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.panel {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
.rail {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
max-height: 45dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
|
|
||||||
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
|
|
||||||
import Search from '$lib/components/recipes/Search.svelte';
|
|
||||||
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
|
|
||||||
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
|
|
||||||
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
card_data = $bindable({}),
|
|
||||||
seasonRanges = $bindable([]),
|
|
||||||
ingredients = $bindable([]),
|
|
||||||
instructions = $bindable([])
|
|
||||||
}: {
|
|
||||||
card_data?: any,
|
|
||||||
seasonRanges?: any[],
|
|
||||||
ingredients?: any[],
|
|
||||||
instructions?: any[]
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let short_name = $state('');
|
|
||||||
let password = $state('');
|
|
||||||
let datecreated = $state(new Date());
|
|
||||||
let datemodified = $state(datecreated);
|
|
||||||
let result = $state('');
|
|
||||||
let image_preview_url = $state('');
|
|
||||||
let selected_image_file = $state<File | null>(null);
|
|
||||||
|
|
||||||
async function doPost () {
|
|
||||||
const res = await fetch('/api/add', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
recipe: {
|
|
||||||
seasonRanges: seasonRanges,
|
|
||||||
...card_data,
|
|
||||||
images: [{
|
|
||||||
mediapath: short_name + '.webp',
|
|
||||||
alt: "",
|
|
||||||
caption: ""
|
|
||||||
}],
|
|
||||||
short_name,
|
|
||||||
datecreated,
|
|
||||||
datemodified,
|
|
||||||
instructions,
|
|
||||||
ingredients,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
bearer: password,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const json = await res.json()
|
|
||||||
result = JSON.stringify(json)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
input.temp{
|
|
||||||
all: unset;
|
|
||||||
display: block;
|
|
||||||
margin: 1rem auto;
|
|
||||||
padding: 0.2em 1em;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background-color: var(--nord4);
|
|
||||||
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<CardAdd bind:card_data={card_data} bind:image_preview_url={image_preview_url} bind:selected_image_file={selected_image_file} {short_name}></CardAdd>
|
|
||||||
|
|
||||||
<input class=temp bind:value={short_name} placeholder="Kurzname"/>
|
|
||||||
|
|
||||||
<SeasonSelect bind:ranges={seasonRanges} />
|
|
||||||
<button onclick={() => console.log(seasonRanges)}>PRINTOUT season</button>
|
|
||||||
|
|
||||||
<h2>Zutaten</h2>
|
|
||||||
<CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList>
|
|
||||||
<h2>Zubereitung</h2>
|
|
||||||
<CreateStepList bind:instructions={instructions} add_info={{}}></CreateStepList>
|
|
||||||
<input class=temp type="password" placeholder=Passwort bind:value={password}>
|
|
||||||
@@ -4,25 +4,50 @@
|
|||||||
import { getStickerById } from '$lib/utils/stickers';
|
import { getStickerById } from '$lib/utils/stickers';
|
||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
eachDayOfInterval, isSameMonth, isToday, format, addMonths, subMonths
|
eachDayOfInterval, isSameMonth, isToday, isWeekend, format, addMonths, subMonths
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
|
|
||||||
let { completions = [], currentUser = '' } = $props();
|
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(
|
// every sticker drop, both members
|
||||||
completions
|
let drops = $derived(completions.filter((/** @type {any} */ c) => c.stickerId));
|
||||||
.filter((/** @type {any} */ c) => c.stickerId)
|
|
||||||
.filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build a map: "YYYY-MM-DD" -> sticker ids[]
|
// Who's visible on the grid. Default: just the current user; others appear
|
||||||
let stickersByDate = $derived.by(() => {
|
// 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[]>} */
|
/** @type {Map<string, any[]>} */
|
||||||
const map = new Map();
|
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');
|
const key = format(new Date(c.completedAt), 'yyyy-MM-dd');
|
||||||
if (!map.has(key)) map.set(key, []);
|
if (!map.has(key)) map.set(key, []);
|
||||||
map.get(key)?.push(c);
|
map.get(key)?.push(c);
|
||||||
@@ -31,59 +56,89 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let calendarDays = $derived.by(() => {
|
let calendarDays = $derived.by(() => {
|
||||||
const monthStart = startOfMonth(viewDate);
|
const calStart = startOfWeek(startOfMonth(viewDate), { locale: de });
|
||||||
const monthEnd = endOfMonth(viewDate);
|
const calEnd = endOfWeek(endOfMonth(viewDate), { locale: de });
|
||||||
const calStart = startOfWeek(monthStart, { locale: de });
|
|
||||||
const calEnd = endOfWeek(monthEnd, { locale: de });
|
|
||||||
return eachDayOfInterval({ start: calStart, end: calEnd });
|
return eachDayOfInterval({ start: calStart, end: calEnd });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let viewDate = $state(new Date());
|
||||||
let monthLabel = $derived(format(viewDate, 'MMMM yyyy', { locale: de }));
|
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 prevMonth() { viewDate = subMonths(viewDate, 1); }
|
||||||
function nextMonth() { viewDate = addMonths(viewDate, 1); }
|
function nextMonth() { viewDate = addMonths(viewDate, 1); }
|
||||||
</script>
|
</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">
|
<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>
|
<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>
|
</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">
|
<div class="cal-grid">
|
||||||
{#each weekdays as day}
|
{#each weekdays as day (day)}
|
||||||
<div class="cal-weekday">{day}</div>
|
<div class="cal-weekday">{day}</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each calendarDays as day}
|
{#each calendarDays as day (day.toISOString())}
|
||||||
{@const key = format(day, 'yyyy-MM-dd')}
|
{@const key = format(day, 'yyyy-MM-dd')}
|
||||||
{@const dayStickers = stickersByDate.get(key) || []}
|
{@const dayDrops = byDate.get(key) || []}
|
||||||
{@const inMonth = isSameMonth(day, viewDate)}
|
{@const inMonth = isSameMonth(day, viewDate)}
|
||||||
<div
|
<div
|
||||||
class="cal-day"
|
class="cal-day"
|
||||||
class:outside={!inMonth}
|
class:outside={!inMonth}
|
||||||
|
class:weekend={isWeekend(day)}
|
||||||
class:today={isToday(day)}
|
class:today={isToday(day)}
|
||||||
class:has-stickers={dayStickers.length > 0}
|
|
||||||
>
|
>
|
||||||
<span class="cal-day-num">{format(day, 'd')}</span>
|
<span class="cal-day-num">{format(day, 'd')}</span>
|
||||||
{#if dayStickers.length > 0}
|
{#if dayDrops.length > 0}
|
||||||
<div class="cal-stickers">
|
<div class="stuck">
|
||||||
{#each dayStickers.slice(0, 6) as completion}
|
{#each dayDrops.slice(0, 4) as c (c._id)}
|
||||||
{@const sticker = getStickerById(completion.stickerId)}
|
{@const sticker = getStickerById(c.stickerId)}
|
||||||
{#if sticker}
|
{#if sticker}
|
||||||
<img
|
{@const tilt = (hash(c._id) % 13) - 6}
|
||||||
class="cal-sticker-img"
|
<span class="cat" style="--tilt: {tilt}deg; --pc: {personColor(c.completedBy)}">
|
||||||
src="/stickers/{sticker.image}"
|
<img src="/stickers/{sticker.image}" alt={sticker.name} title="{sticker.name} — {c.taskTitle} ({c.completedBy})" loading="lazy" />
|
||||||
alt={sticker.name}
|
<span class="who-dot"></span>
|
||||||
title="{sticker.name} — {completion.taskTitle}"
|
</span>
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if dayStickers.length > 6}
|
{#if dayDrops.length > 4}
|
||||||
<span class="cal-more">+{dayStickers.length - 6}</span>
|
<span class="more">+{dayDrops.length - 4}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -93,27 +148,48 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cal-container {
|
/* warm paper page (matches the sticker album) — stays cream in both themes */
|
||||||
background: var(--color-bg-primary, white);
|
.cal-page {
|
||||||
border: 1px solid var(--color-border, #e8e4dd);
|
position: relative;
|
||||||
border-radius: 14px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
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 {
|
.cal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.cal-month {
|
.cal-month {
|
||||||
font-size: 1rem;
|
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
min-width: 160px;
|
min-width: 180px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: #5a4a2c;
|
||||||
}
|
}
|
||||||
.cal-nav {
|
.cal-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -123,132 +199,135 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-secondary, #888);
|
color: #8a7747;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 120ms;
|
transition: all 120ms;
|
||||||
}
|
}
|
||||||
.cal-nav:hover {
|
.cal-nav:hover { background: rgba(138, 119, 71, 0.14); color: #5a4a2c; }
|
||||||
background: var(--color-bg-secondary, #f0ede6);
|
|
||||||
color: var(--color-text-primary, #333);
|
.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 {
|
.cal-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
gap: 1px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cal-weekday {
|
.cal-weekday {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.68rem;
|
font-size: 0.66rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-secondary, #999);
|
color: #9a865a;
|
||||||
padding: 0.3rem 0;
|
padding: 0.2rem 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cal-day {
|
.cal-day {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 80px;
|
min-height: 78px;
|
||||||
padding: 0.3rem;
|
padding: 0.3rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid transparent;
|
border: 1px dashed 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);
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.cal-day.today .cal-day-num {
|
||||||
background: var(--nord10);
|
background: var(--nord10);
|
||||||
color: white;
|
color: #fff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 20px;
|
width: 19px;
|
||||||
height: 20px;
|
height: 19px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.cal-day.has-stickers {
|
|
||||||
background: rgba(163, 190, 140, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cal-day-num {
|
.cal-day-num {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--color-text-secondary, #888);
|
color: #8a7747;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cal-stickers {
|
.stuck {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 3px;
|
gap: 3px 2px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.cal-sticker-img {
|
/* a cat sticker "stuck" on the date — die-cut white edge + hand tilt */
|
||||||
width: 28px;
|
.cat {
|
||||||
height: 28px;
|
position: relative;
|
||||||
object-fit: contain;
|
transform: rotate(var(--tilt));
|
||||||
transition: transform 150ms;
|
transition: transform 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.cal-sticker-img:hover {
|
.cat img {
|
||||||
transform: scale(2);
|
display: block;
|
||||||
z-index: 10;
|
width: 27px;
|
||||||
position: relative;
|
height: 27px;
|
||||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));
|
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-size: 0.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-secondary, #aaa);
|
color: #9a865a;
|
||||||
display: flex;
|
align-self: center;
|
||||||
align-items: center;
|
padding-left: 1px;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
.cal-day { min-height: 56px; padding: 0.2rem; }
|
.cal-day { min-height: 58px; padding: 0.2rem; }
|
||||||
.cal-sticker-img { width: 22px; height: 22px; }
|
.cat img { width: 21px; height: 21px; }
|
||||||
.cal-stickers { gap: 2px; }
|
.cal-month { font-size: 1.25rem; min-width: 140px; }
|
||||||
.cal-month { font-size: 0.9rem; min-width: 130px; }
|
.tape { display: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Client-side image editing pipeline (no DOM / Svelte deps).
|
||||||
|
*
|
||||||
|
* The browser already ships a WebP encoder via `canvas.toBlob(cb, 'image/webp', q)`.
|
||||||
|
* Crop, scale-to-fit and the size readout are built on top of `<canvas>`; the
|
||||||
|
* encode is the only primitive we don't hand-roll. `sharp` cannot run here — it's
|
||||||
|
* a native Node binding — so all of this happens on the main thread.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CropRect = { x: number; y: number; w: number; h: number };
|
||||||
|
export type Size = { w: number; h: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a File into an ImageBitmap, honouring EXIF orientation so that
|
||||||
|
* sideways phone photos render upright.
|
||||||
|
*/
|
||||||
|
export async function loadBitmap(file: File): Promise<ImageBitmap> {
|
||||||
|
try {
|
||||||
|
return await createImageBitmap(file, { imageOrientation: 'from-image' });
|
||||||
|
} catch {
|
||||||
|
// Older Safari ignores the options bag — fall back to the plain call.
|
||||||
|
return await createImageBitmap(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale `w`×`h` to fit inside a `max`×`max` box, preserving aspect ratio.
|
||||||
|
* Never upscales (a smaller source is returned untouched). `max <= 0` means
|
||||||
|
* "no limit" (Original).
|
||||||
|
*/
|
||||||
|
export function fitWithin(w: number, h: number, max: number): Size {
|
||||||
|
if (max <= 0 || (w <= max && h <= max)) {
|
||||||
|
return { w: Math.round(w), h: Math.round(h) };
|
||||||
|
}
|
||||||
|
const scale = Math.min(max / w, max / h);
|
||||||
|
return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crop `bitmap` to `crop` (source pixels), scale the result to fit `maxRes`,
|
||||||
|
* and encode as WebP at `quality` (1–100). Returns the encoded Blob; read
|
||||||
|
* `.size` for the final byte count.
|
||||||
|
*/
|
||||||
|
export async function renderToBlob(
|
||||||
|
bitmap: ImageBitmap,
|
||||||
|
crop: CropRect,
|
||||||
|
maxRes: number,
|
||||||
|
quality: number
|
||||||
|
): Promise<Blob> {
|
||||||
|
const out = fitWithin(crop.w, crop.h, maxRes);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = out.w;
|
||||||
|
canvas.height = out.h;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('2D canvas context unavailable');
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(bitmap, crop.x, crop.y, crop.w, crop.h, 0, 0, out.w, out.h);
|
||||||
|
|
||||||
|
return await new Promise<Blob>((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) => (blob ? resolve(blob) : reject(new Error('WebP encoding failed'))),
|
||||||
|
'image/webp',
|
||||||
|
Math.min(1, Math.max(0.01, quality / 100))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap an encoded Blob as a File the form can upload. */
|
||||||
|
export function blobToFile(blob: Blob, shortName: string): File {
|
||||||
|
const base = (shortName || 'image').trim() || 'image';
|
||||||
|
return new File([blob], `${base}.webp`, { type: 'image/webp' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable byte size, e.g. "412 KB" / "1.3 MB". */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
@@ -172,6 +172,18 @@ const DIFFICULTY_RARITY_WEIGHTS: Record<string, Record<string, number>> = {
|
|||||||
high: { common: 25, uncommon: 30, rare: 30, legendary: 15 },
|
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 {
|
export function getStickerForTags(tags: string[], difficulty?: string): Sticker {
|
||||||
const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium;
|
const weights = DIFFICULTY_RARITY_WEIGHTS[difficulty || 'medium'] || DIFFICULTY_RARITY_WEIGHTS.medium;
|
||||||
|
|
||||||
|
|||||||
@@ -80,10 +80,10 @@
|
|||||||
// card pairs into the hero and the rest fly out.)
|
// card pairs into the hero and the rest fly out.)
|
||||||
// - vt-enter-hike-detail: arriving at a hike detail page (card → zoom).
|
// - vt-enter-hike-detail: arriving at a hike detail page (card → zoom).
|
||||||
// - vt-exit-hike-detail: leaving a hike detail page for anywhere
|
// - vt-exit-hike-detail: leaving a hike detail page for anywhere
|
||||||
// else (back to /hikes, off to /, route-builder, …) → photo strip
|
// else (back to /hikes, off to /, route-builder, …) → the whole
|
||||||
// slides back out to the right and the below-strip block flies
|
// below-map panel flies back down off the bottom. Excluded for
|
||||||
// down. Excluded for slug → slug navigations (both sides share the
|
// slug → slug navigations (both sides share the same route.id, so
|
||||||
// same route.id, so paired UA transitions handle them).
|
// paired UA transitions handle them).
|
||||||
const intoHikesIndex = toId === '/hikes' && fromId !== '/hikes';
|
const intoHikesIndex = toId === '/hikes' && fromId !== '/hikes';
|
||||||
const outOfHikesIndex = fromId === '/hikes' && toId !== '/hikes';
|
const outOfHikesIndex = fromId === '/hikes' && toId !== '/hikes';
|
||||||
const intoHikeDetail = toId === '/hikes/[slug]';
|
const intoHikeDetail = toId === '/hikes/[slug]';
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { processAndSaveRecipeImage } from '$utils/imageProcessing';
|
|||||||
import {
|
import {
|
||||||
extractRecipeFromFormData,
|
extractRecipeFromFormData,
|
||||||
validateRecipeData,
|
validateRecipeData,
|
||||||
serializeRecipeForDatabase
|
serializeRecipeForDatabase,
|
||||||
|
serializableFormValues
|
||||||
} from '$utils/recipeFormHelpers';
|
} from '$utils/recipeFormHelpers';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({locals, params}) => {
|
export const load: PageServerLoad = async ({locals, params}) => {
|
||||||
@@ -51,7 +52,7 @@ export const actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: validationErrors.join(', '),
|
error: validationErrors.join(', '),
|
||||||
errors: validationErrors,
|
errors: validationErrors,
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export const actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: `Failed to process image: ${message}`,
|
error: `Failed to process image: ${message}`,
|
||||||
errors: ['Image processing failed'],
|
errors: ['Image processing failed'],
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -127,7 +128,7 @@ export const actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
|
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
|
||||||
errors: ['Duplicate short_name'],
|
errors: ['Duplicate short_name'],
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ export const actions = {
|
|||||||
return fail(500, {
|
return fail(500, {
|
||||||
error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`,
|
error: `Failed to create recipe: ${dbMessage || 'Unknown database error'}`,
|
||||||
errors: [dbMessage],
|
errors: [dbMessage],
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import type { ActionData, PageData } from './$types';
|
import type { ActionData, PageData } from './$types';
|
||||||
import Check from '$lib/assets/icons/Check.svelte';
|
import SaveFab from '$lib/components/SaveFab.svelte';
|
||||||
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
|
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
|
||||||
import TranslationApproval from '$lib/components/recipes/TranslationApproval.svelte';
|
import TranslationApproval from '$lib/components/recipes/TranslationApproval.svelte';
|
||||||
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
|
import EditTitleImgParallax from '$lib/components/recipes/EditTitleImgParallax.svelte';
|
||||||
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
|
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
|
||||||
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
|
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
|
||||||
import { toast } from '$lib/js/toast.svelte';
|
import { toast } from '$lib/js/toast.svelte';
|
||||||
import Toggle from '$lib/components/Toggle.svelte';
|
import Toggle from '$lib/components/Toggle.svelte';
|
||||||
import '$lib/css/action_button.css';
|
|
||||||
|
|
||||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
@@ -87,20 +86,30 @@
|
|||||||
defaultForm,
|
defaultForm,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show translation workflow before submission
|
function validate(): boolean {
|
||||||
function prepareSubmit() {
|
|
||||||
// Client-side validation
|
|
||||||
if (!short_name.trim()) {
|
if (!short_name.trim()) {
|
||||||
toast.error('Bitte geben Sie einen Kurznamen ein');
|
toast.error('Bitte geben Sie einen Kurznamen ein');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (!card_data.name) {
|
if (!card_data.name) {
|
||||||
toast.error('Bitte geben Sie einen Namen ein');
|
toast.error('Bitte geben Sie einen Namen ein');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directly without an English translation (mirrors /edit's SaveFab).
|
||||||
|
async function saveRecipe() {
|
||||||
|
if (!validate()) return;
|
||||||
|
translationData = null;
|
||||||
|
await tick();
|
||||||
|
formElement?.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the optional translation workflow before submission.
|
||||||
|
function openTranslation() {
|
||||||
|
if (!validate()) return;
|
||||||
showTranslationWorkflow = true;
|
showTranslationWorkflow = true;
|
||||||
// Scroll to translation section
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
|
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, 100);
|
}, 100);
|
||||||
@@ -147,141 +156,322 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
input {
|
h3 {
|
||||||
display: block;
|
text-align: center;
|
||||||
border: unset;
|
font-size: 1.15rem;
|
||||||
margin: 1rem auto;
|
letter-spacing: 0.02em;
|
||||||
padding: 0.5em 1em;
|
margin-block: 1.25rem 0.75rem;
|
||||||
border-radius: var(--radius-pill);
|
color: var(--color-text-primary);
|
||||||
background-color: var(--nord4);
|
}
|
||||||
font-size: 1.1rem;
|
|
||||||
transition: var(--transition-fast);
|
/* ===== Below-hero content wrapper: full-width backdrop hides the sticky hero ===== */
|
||||||
}
|
.below-hero {
|
||||||
input:hover,
|
--bg-color: var(--color-bg-primary);
|
||||||
input:focus-visible {
|
position: relative;
|
||||||
scale: 1.05 1.05;
|
max-width: 1000px;
|
||||||
}
|
margin: 0 auto;
|
||||||
.list_wrapper {
|
padding: 2rem 1rem 4rem;
|
||||||
margin-inline: auto;
|
}
|
||||||
display: flex;
|
.below-hero::before {
|
||||||
flex-direction: row;
|
content: '';
|
||||||
max-width: 1000px;
|
position: absolute;
|
||||||
gap: 2rem;
|
inset: 0;
|
||||||
justify-content: center;
|
left: 50%;
|
||||||
}
|
transform: translateX(-50%);
|
||||||
@media screen and (max-width: 700px) {
|
width: 100vw;
|
||||||
.list_wrapper {
|
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;
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
}
|
.url-field input {
|
||||||
h1 {
|
display: block;
|
||||||
text-align: center;
|
border: 1px solid var(--color-border);
|
||||||
margin-bottom: 2rem;
|
margin: 0;
|
||||||
}
|
padding: 0.55em 1.1em;
|
||||||
.title_container {
|
border-radius: var(--radius-pill);
|
||||||
max-width: 1000px;
|
background-color: var(--color-bg-tertiary);
|
||||||
display: flex;
|
color: var(--color-text-primary);
|
||||||
flex-direction: column;
|
font-size: 1rem;
|
||||||
margin-inline: auto;
|
font-weight: 400;
|
||||||
}
|
letter-spacing: 0;
|
||||||
.title {
|
text-transform: none;
|
||||||
position: relative;
|
min-width: 16rem;
|
||||||
width: min(800px, 80vw);
|
transition: var(--transition-fast);
|
||||||
margin-block: 2rem;
|
}
|
||||||
margin-inline: auto;
|
.url-field input:hover,
|
||||||
background-color: var(--nord6);
|
.url-field input:focus-visible {
|
||||||
padding: 1rem 2rem;
|
border-color: var(--color-primary);
|
||||||
}
|
outline: none;
|
||||||
.title p {
|
}
|
||||||
border: 2px solid var(--nord1);
|
.toggle-field {
|
||||||
border-radius: 10000px;
|
align-self: center;
|
||||||
padding: 0.5em 1em;
|
}
|
||||||
font-size: 1.1rem;
|
|
||||||
transition: var(--transition-normal);
|
/* ===== Ingredients + Instructions two-col ===== */
|
||||||
}
|
.list_wrapper {
|
||||||
.title p:hover,
|
margin-inline: auto;
|
||||||
.title p:focus-within {
|
display: flex;
|
||||||
scale: 1.02 1.02;
|
flex-direction: row;
|
||||||
}
|
max-width: 1000px;
|
||||||
.addendum {
|
gap: 2rem;
|
||||||
font-size: 1.1rem;
|
justify-content: center;
|
||||||
max-width: 90%;
|
margin-block: 2.5rem;
|
||||||
margin-inline: auto;
|
}
|
||||||
border: 2px solid var(--nord1);
|
@media screen and (max-width: 700px) {
|
||||||
border-radius: 45px;
|
.list_wrapper {
|
||||||
padding: 1em 1em;
|
flex-direction: column;
|
||||||
transition: var(--transition-fast);
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.addendum:hover,
|
}
|
||||||
.addendum:focus-within {
|
|
||||||
scale: 1.02 1.02;
|
/* ===== Addendum ===== */
|
||||||
}
|
.addendum_wrapper {
|
||||||
.addendum_wrapper {
|
max-width: 1000px;
|
||||||
max-width: 1000px;
|
margin: 2.5rem auto;
|
||||||
margin-inline: auto;
|
}
|
||||||
}
|
.addendum {
|
||||||
h3 {
|
font-size: 1.05rem;
|
||||||
text-align: center;
|
max-width: min(720px, 100%);
|
||||||
}
|
margin-inline: auto;
|
||||||
button.action_button {
|
padding: 1em 1.25em;
|
||||||
animation: unset !important;
|
background: var(--color-bg-primary);
|
||||||
font-size: 1.3rem;
|
border: 1px solid var(--color-border);
|
||||||
color: white;
|
border-radius: var(--radius-md);
|
||||||
}
|
color: var(--color-text-primary);
|
||||||
.submit_buttons {
|
min-height: 3em;
|
||||||
display: flex;
|
outline: none;
|
||||||
margin-inline: auto;
|
transition: border-color 200ms ease;
|
||||||
max-width: 1000px;
|
}
|
||||||
margin-block: 1rem;
|
.addendum:hover,
|
||||||
justify-content: center;
|
.addendum:focus-visible {
|
||||||
align-items: center;
|
border-color: var(--color-primary);
|
||||||
gap: 2rem;
|
}
|
||||||
}
|
.addendum:empty::before {
|
||||||
.submit_buttons p {
|
content: attr(data-placeholder);
|
||||||
padding: 0;
|
color: var(--color-text-tertiary);
|
||||||
padding-right: 0.5em;
|
font-style: italic;
|
||||||
margin: 0;
|
}
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* ===== Form-size / Backform ===== */
|
||||||
:global(:root:not([data-theme="light"])) .title {
|
.form-size-section {
|
||||||
background-color: var(--nord6-dark);
|
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>
|
</style>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -289,8 +479,6 @@ button.action_button {
|
|||||||
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
|
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1>Rezept erstellen</h1>
|
|
||||||
|
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="error-message">
|
<div class="error-message">
|
||||||
<strong>Fehler:</strong> {form.error}
|
<strong>Fehler:</strong> {form.error}
|
||||||
@@ -330,16 +518,6 @@ button.action_button {
|
|||||||
})} />
|
})} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<CardAdd
|
|
||||||
bind:card_data
|
|
||||||
bind:image_preview_url
|
|
||||||
bind:selected_image_file
|
|
||||||
short_name={short_name}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h3>Kurzname (für URL):</h3>
|
|
||||||
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
|
|
||||||
|
|
||||||
<!-- Hidden inputs for card data -->
|
<!-- Hidden inputs for card data -->
|
||||||
<input type="hidden" name="name" value={card_data.name} />
|
<input type="hidden" name="name" value={card_data.name} />
|
||||||
<input type="hidden" name="description" value={card_data.description} />
|
<input type="hidden" name="description" value={card_data.description} />
|
||||||
@@ -348,99 +526,187 @@ button.action_button {
|
|||||||
<input type="hidden" name="portions" value={portions_local} />
|
<input type="hidden" name="portions" value={portions_local} />
|
||||||
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
|
<input type="hidden" name="isBaseRecipe" value={isBaseRecipe ? "true" : "false"} />
|
||||||
<input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} />
|
<input type="hidden" name="defaultForm_json" value={defaultForm ? JSON.stringify(defaultForm) : ''} />
|
||||||
|
<input type="hidden" name="preamble" value={preamble} />
|
||||||
|
|
||||||
<div style="text-align: center; margin: 1rem;">
|
<EditTitleImgParallax
|
||||||
<Toggle
|
bind:card_data
|
||||||
bind:checked={isBaseRecipe}
|
bind:image_preview_url
|
||||||
label="Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)"
|
bind:selected_image_file
|
||||||
/>
|
>
|
||||||
</div>
|
{#snippet titleExtras()}
|
||||||
|
<h2 class="section-label">Saison</h2>
|
||||||
<!-- Default Form (Cake Pan) -->
|
<div class="season-wrapper">
|
||||||
<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>
|
|
||||||
<SeasonSelect bind:ranges={season_local} />
|
<SeasonSelect bind:ranges={season_local} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="list_wrapper">
|
<h2 class="section-label">Einleitung</h2>
|
||||||
<div>
|
<p
|
||||||
<CreateIngredientList bind:ingredients />
|
class="preamble"
|
||||||
</div>
|
contenteditable="plaintext-only"
|
||||||
<div>
|
bind:innerText={preamble}
|
||||||
<CreateStepList bind:instructions bind:add_info />
|
data-placeholder="Eine etwas längere Einleitung für dieses Rezept…"
|
||||||
</div>
|
aria-label="Einleitung"
|
||||||
</div>
|
></p>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<div class="addendum_wrapper">
|
<div class="below-hero">
|
||||||
<h3>Nachtrag:</h3>
|
<div class="meta-row">
|
||||||
<div class="addendum" bind:innerText={addendum} contenteditable></div>
|
<label class="url-field">
|
||||||
<input type="hidden" name="addendum" value={addendum} />
|
<span>URL-Kurzname</span>
|
||||||
</div>
|
<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="form-size-section">
|
||||||
<div class="submit_buttons">
|
<div class="form-size-head">
|
||||||
<button
|
<span class="form-size-title">Backform (Standard)</span>
|
||||||
type="button"
|
</div>
|
||||||
class="action_button"
|
<div class="form-size-body">
|
||||||
onclick={prepareSubmit}
|
<div class="form-shape-row" role="radiogroup" aria-label="Backform">
|
||||||
disabled={submitting}
|
<button
|
||||||
>
|
type="button"
|
||||||
<p>Weiter zur Übersetzung</p>
|
role="radio"
|
||||||
<Check fill="white" width="2rem" height="2rem" />
|
aria-checked={!defaultForm}
|
||||||
</button>
|
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>
|
</div>
|
||||||
{/if}
|
</EditTitleImgParallax>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<SaveFab type="button" onclick={saveRecipe} disabled={submitting} label="Rezept erstellen" />
|
||||||
|
|
||||||
{#if showTranslationWorkflow}
|
{#if showTranslationWorkflow}
|
||||||
<div id="translation-section">
|
<div id="translation-section">
|
||||||
<TranslationApproval
|
<TranslationApproval
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
extractRecipeFromFormData,
|
extractRecipeFromFormData,
|
||||||
validateRecipeData,
|
validateRecipeData,
|
||||||
serializeRecipeForDatabase,
|
serializeRecipeForDatabase,
|
||||||
detectChangedFields
|
detectChangedFields,
|
||||||
|
serializableFormValues
|
||||||
} from '$utils/recipeFormHelpers';
|
} from '$utils/recipeFormHelpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +99,7 @@ export const actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: 'Original short name is required for edit',
|
error: 'Original short name is required for edit',
|
||||||
errors: ['Missing original_short_name'],
|
errors: ['Missing original_short_name'],
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export const actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: validationErrors.join(', '),
|
error: validationErrors.join(', '),
|
||||||
errors: validationErrors,
|
errors: validationErrors,
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ export const actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: `Failed to process image: ${message}`,
|
error: `Failed to process image: ${message}`,
|
||||||
errors: ['Image processing failed'],
|
errors: ['Image processing failed'],
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (keepExistingImage && existingImagePath) {
|
} else if (keepExistingImage && existingImagePath) {
|
||||||
@@ -206,7 +207,7 @@ export const actions = {
|
|||||||
return fail(404, {
|
return fail(404, {
|
||||||
error: `Recipe with short name "${originalShortName}" not found`,
|
error: `Recipe with short name "${originalShortName}" not found`,
|
||||||
errors: ['Recipe not found'],
|
errors: ['Recipe not found'],
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +264,7 @@ export const actions = {
|
|||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
|
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
|
||||||
errors: ['Duplicate short_name'],
|
errors: ['Duplicate short_name'],
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +272,7 @@ export const actions = {
|
|||||||
return fail(500, {
|
return fail(500, {
|
||||||
error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`,
|
error: `Failed to update recipe: ${dbMessage || 'Unknown database error'}`,
|
||||||
errors: [dbMessage],
|
errors: [dbMessage],
|
||||||
values: Object.fromEntries(formData)
|
values: serializableFormValues(formData)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Task } from '$models/Task';
|
|||||||
import { TaskCompletion } from '$models/TaskCompletion';
|
import { TaskCompletion } from '$models/TaskCompletion';
|
||||||
import { dbConnect } from '$utils/db';
|
import { dbConnect } from '$utils/db';
|
||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import { getStickerForTags } from '$lib/utils/stickers';
|
import { getStickerForTags, getStickerById } from '$lib/utils/stickers';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
|
|
||||||
function getNextDueDate(completedAt: Date, frequencyType: string, customDays?: number): Date {
|
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();
|
const now = new Date();
|
||||||
|
|
||||||
// Award a sticker based on task tags and difficulty
|
// Award a sticker. The client rolls + displays it optimistically and passes
|
||||||
const sticker = getStickerForTags(task.tags, task.difficulty || 'medium');
|
// 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
|
// Record the completion
|
||||||
const completion = await TaskCompletion.create({
|
const completion = await TaskCompletion.create({
|
||||||
|
|||||||
@@ -415,21 +415,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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}
|
{#if hasStages && stages}
|
||||||
<HikeStageNav {stages} />
|
<HikeStageNav {stages} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if track && track.length > 0 && visibleImagePoints.length > 0}
|
{#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} />
|
<HikePhotoStrip images={visibleImagePoints} {track} {stages} />
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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">
|
<section class="metrics" aria-label="Tourendaten">
|
||||||
{#if hike.icon}
|
{#if hike.icon}
|
||||||
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
|
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
|
||||||
@@ -985,6 +986,15 @@
|
|||||||
opacity: 0.55;
|
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 {
|
.strip-area {
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import TaskForm from '$lib/components/tasks/TaskForm.svelte';
|
import TaskForm from '$lib/components/tasks/TaskForm.svelte';
|
||||||
import StickerPopup from '$lib/components/tasks/StickerPopup.svelte';
|
import StickerPopup from '$lib/components/tasks/StickerPopup.svelte';
|
||||||
|
import { getStickerForTags } from '$lib/utils/stickers';
|
||||||
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -112,16 +113,25 @@
|
|||||||
* @param {string} [forUser]
|
* @param {string} [forUser]
|
||||||
*/
|
*/
|
||||||
async function completeTask(task, 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`, {
|
const res = await fetch(`/api/tasks/${task._id}/complete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(forUser ? { completedFor: forUser } : {})
|
body: JSON.stringify({ stickerId: sticker.id, ...(forUser ? { completedFor: forUser } : {}) })
|
||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const result = await res.json();
|
|
||||||
|
|
||||||
awardedSticker = result.sticker;
|
|
||||||
completeForTaskId = null;
|
|
||||||
await refreshTasks();
|
await refreshTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +1,100 @@
|
|||||||
<script>
|
<script>
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||||
import { STICKERS, getStickerById, getRarityColor } from '$lib/utils/stickers';
|
import { STICKERS, getStickerById, ALWAYS_CATEGORIES, getTagsForCategory } from '$lib/utils/stickers';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow, format } from 'date-fns';
|
||||||
import { de } from 'date-fns/locale';
|
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 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 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();
|
let { data } = $props();
|
||||||
|
|
||||||
/** @type {import('$lib/utils/stickers').Sticker | null} */
|
/** @type {import('$lib/utils/stickers').Sticker | null} */
|
||||||
let selectedSticker = $state(null);
|
let selected = $state(null);
|
||||||
|
|
||||||
let stats = $derived(data.stats || { userStats: [], userStickers: [], recentCompletions: [] });
|
let stats = $derived(data.stats || { userStats: [], userStickers: [], recentCompletions: [] });
|
||||||
let currentUser = $derived(data.session?.user?.nickname || '');
|
let currentUser = $derived(data.session?.user?.nickname || '');
|
||||||
|
|
||||||
const rarityLabels = /** @type {Record<string, string>} */ ({
|
// id -> times earned (current user)
|
||||||
common: 'Gewöhnlich',
|
let counts = $derived.by(() => {
|
||||||
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(() => {
|
|
||||||
/** @type {Map<string, number>} */
|
/** @type {Map<string, number>} */
|
||||||
const collection = new Map();
|
const m = new Map();
|
||||||
for (const entry of stats.userStickers) {
|
for (const entry of stats.userStickers) {
|
||||||
if (entry._id.user === currentUser) {
|
if (entry._id.user === currentUser) m.set(entry._id.sticker, entry.count);
|
||||||
collection.set(entry._id.sticker, entry.count);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return collection;
|
return m;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort stickers for display: owned first (by rarity), then unowned
|
// album "pages" by category
|
||||||
let sortedStickers = $derived.by(() => {
|
const PAGES = [
|
||||||
return [...STICKERS].sort((a, b) => {
|
{ cat: 'general', name: 'Allerlei' },
|
||||||
const aOwned = displayedStickers.has(a.id);
|
{ cat: 'kitchen', name: 'Küche' },
|
||||||
const bOwned = displayedStickers.has(b.id);
|
{ cat: 'cozy', name: 'Gemütlichkeit' },
|
||||||
if (aOwned && !bOwned) return -1;
|
{ cat: 'plants', name: 'Pflanzen & Garten' },
|
||||||
if (!aOwned && bOwned) return 1;
|
{ cat: 'cleaning', name: 'Sauberkeit' },
|
||||||
const rarityDiff = (rarityOrder[a.rarity] ?? 3) - (rarityOrder[b.rarity] ?? 3);
|
{ cat: 'errands', name: 'Erledigungen' },
|
||||||
if (rarityDiff !== 0) return rarityDiff;
|
{ cat: 'achievement', name: 'Erfolge' },
|
||||||
return a.name.localeCompare(b.name, 'de');
|
{ 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 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
|
// Recent completions with stickers
|
||||||
let recentWithStickers = $derived(
|
let recentWithStickers = $derived(
|
||||||
stats.recentCompletions
|
stats.recentCompletions
|
||||||
@@ -89,54 +124,59 @@
|
|||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div class="progress-fill" style="width: {(collectedCount / totalCount) * 100}%"></div>
|
<div class="progress-fill" style="width: {(collectedCount / totalCount) * 100}%"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<StickerCalendar completions={stats.recentCompletions} {currentUser} />
|
<StickerCalendar completions={stats.recentCompletions} {currentUser} />
|
||||||
|
|
||||||
<h2 class="section-title">Alle Sticker</h2>
|
<h2 class="section-title">Alle Sticker</h2>
|
||||||
<div class="sticker-grid">
|
{#each pages as page (page.cat)}
|
||||||
{#each sortedStickers as sticker (sticker.id)}
|
<section class="page">
|
||||||
{@const count = displayedStickers.get(sticker.id) || 0}
|
<div class="page-head">
|
||||||
{@const owned = count > 0}
|
<div class="ph-title">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<h3>{page.name}</h3>
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<button
|
||||||
<div
|
class="info-btn"
|
||||||
class="sticker-card"
|
class:open={openInfo === page.cat}
|
||||||
class:owned
|
aria-label="Wie bekomme ich diese Sticker?"
|
||||||
class:locked={!owned}
|
aria-expanded={openInfo === page.cat}
|
||||||
animate:flip={{ duration: 300 }}
|
onclick={() => (openInfo = openInfo === page.cat ? '' : page.cat)}
|
||||||
style="--rarity-color: {getRarityColor(sticker.rarity)}"
|
>i</button>
|
||||||
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}
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="page-count">{page.owned}/{page.items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{#if openInfo === page.cat}
|
||||||
</div>
|
<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}
|
{#if recentWithStickers.length > 0}
|
||||||
<section class="recent-section">
|
<section class="recent-section">
|
||||||
<h2>Letzte Sticker</h2>
|
<h2>Letzte Sticker</h2>
|
||||||
<div class="recent-list">
|
<div class="recent-list">
|
||||||
{#each recentWithStickers as completion}
|
{#each recentWithStickers as completion (completion._id)}
|
||||||
{@const sticker = getStickerById(completion.stickerId)}
|
{@const sticker = getStickerById(completion.stickerId)}
|
||||||
{#if sticker}
|
{#if sticker}
|
||||||
<div class="recent-item">
|
<div class="recent-item">
|
||||||
@@ -159,8 +199,15 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if selectedSticker}
|
{#if selected}
|
||||||
<StickerPopup sticker={selectedSticker} title={selectedSticker.name} buttonText="Schließen" bounce={false} onclose={() => selectedSticker = null} />
|
{@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}
|
{/if}
|
||||||
|
|
||||||
<div class="danger-zone">
|
<div class="danger-zone">
|
||||||
@@ -173,7 +220,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.rewards-page {
|
.rewards-page {
|
||||||
max-width: 900px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
}
|
}
|
||||||
@@ -209,102 +256,102 @@
|
|||||||
transition: width 500ms ease;
|
transition: width 500ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 0.75rem;
|
margin: 1.5rem 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticker grid */
|
/* sticker album pages */
|
||||||
.sticker-grid {
|
.page {
|
||||||
display: grid;
|
margin-bottom: 1.25rem;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
padding: 1rem 1rem 1.25rem;
|
||||||
gap: 0.75rem;
|
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);
|
||||||
}
|
}
|
||||||
|
.page-head {
|
||||||
.sticker-card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: baseline;
|
||||||
align-items: center;
|
justify-content: space-between;
|
||||||
padding: 1rem 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
border-radius: 14px;
|
padding-bottom: 0.4rem;
|
||||||
border: 1px solid var(--color-border, #e8e4dd);
|
border-bottom: 2px dashed #cdbf9d;
|
||||||
background: var(--color-bg-primary, white);
|
|
||||||
transition: transform 150ms, box-shadow 150ms;
|
|
||||||
}
|
}
|
||||||
.sticker-card.owned {
|
.page-head h3 {
|
||||||
border-color: var(--rarity-color);
|
margin: 0;
|
||||||
border-width: 1.5px;
|
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||||
cursor: pointer;
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #5a4a2c;
|
||||||
}
|
}
|
||||||
.sticker-card.owned:hover {
|
.ph-title { display: flex; align-items: center; gap: 0.45rem; }
|
||||||
transform: translateY(-2px);
|
.info-btn {
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
|
width: 18px;
|
||||||
}
|
height: 18px;
|
||||||
.sticker-card.locked {
|
flex-shrink: 0;
|
||||||
opacity: 0.4;
|
display: inline-flex;
|
||||||
filter: grayscale(0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticker-visual {
|
|
||||||
position: relative;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 0.4rem;
|
border: 1.5px solid #b9a877;
|
||||||
}
|
background: transparent;
|
||||||
.owned .sticker-visual {
|
color: #8a7747;
|
||||||
background: radial-gradient(circle, var(--rarity-color) 0%, transparent 70%);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
opacity: 0.95;
|
font-family: Georgia, serif;
|
||||||
}
|
font-style: italic;
|
||||||
.sticker-img {
|
font-size: 0.72rem;
|
||||||
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-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-secondary, #ccc);
|
line-height: 1;
|
||||||
opacity: 0.4;
|
cursor: pointer;
|
||||||
|
transition: all 120ms;
|
||||||
}
|
}
|
||||||
.sticker-count {
|
.info-btn:hover, .info-btn.open {
|
||||||
position: absolute;
|
background: #8a7747;
|
||||||
bottom: -2px;
|
color: #f3ecd9;
|
||||||
right: -2px;
|
border-color: #8a7747;
|
||||||
background: var(--nord10);
|
}
|
||||||
color: white;
|
.page-count {
|
||||||
font-size: 0.65rem;
|
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 0.1rem 0.35rem;
|
font-size: 0.8rem;
|
||||||
border-radius: 100px;
|
color: #8a7747;
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
|
.earn-info {
|
||||||
.sticker-info {
|
margin: 0 0 0.7rem;
|
||||||
text-align: center;
|
padding: 0.5rem 0.7rem;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1rem;
|
|
||||||
}
|
|
||||||
.sticker-name {
|
|
||||||
font-size: 0.78rem;
|
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;
|
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 {
|
.sheet {
|
||||||
font-size: 0.62rem;
|
display: grid;
|
||||||
font-weight: 700;
|
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||||
text-transform: uppercase;
|
gap: 0.4rem 0.2rem;
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
}
|
||||||
.sticker-desc {
|
/* the album sheet is a physical page — stays warm in dark mode */
|
||||||
font-size: 0.68rem;
|
:global(:root[data-theme='dark']) .page,
|
||||||
color: var(--color-text-secondary, #999);
|
:global(:root:not([data-theme='light'])) .page {
|
||||||
|
background-color: #ece3cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recent section */
|
/* Recent section */
|
||||||
@@ -371,13 +418,6 @@
|
|||||||
|
|
||||||
/* Dark mode */
|
/* Dark mode */
|
||||||
@media (prefers-color-scheme: dark) {
|
@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 {
|
:global(:root:not([data-theme="light"])) .recent-item {
|
||||||
background: var(--nord1);
|
background: var(--nord1);
|
||||||
border-color: var(--nord2);
|
border-color: var(--nord2);
|
||||||
@@ -386,13 +426,6 @@
|
|||||||
background: var(--nord2);
|
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 {
|
:global(:root[data-theme="dark"]) .recent-item {
|
||||||
background: var(--nord1);
|
background: var(--nord1);
|
||||||
border-color: var(--nord2);
|
border-color: var(--nord2);
|
||||||
@@ -426,13 +459,4 @@
|
|||||||
border-color: var(--nord11);
|
border-color: var(--nord11);
|
||||||
background: rgba(191, 97, 106, 0.06);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
||||||
import { validateImageFile } from '$utils/imageValidation';
|
import { validateImageFile } from '$utils/imageValidation';
|
||||||
@@ -132,21 +133,34 @@ export async function processAndSaveRecipeImage(
|
|||||||
unhashed: unhashedFilename
|
unhashed: unhashedFilename
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process image with Sharp - convert to WebP format
|
// Full size: the client photo editor already crops, scales and encodes WebP at
|
||||||
// Save full size - both hashed and unhashed versions
|
// the user's chosen quality. Store that byte-for-byte so the on-disk file matches
|
||||||
console.log('[ImageProcessing] Converting to WebP and generating full size...');
|
// the size the user saw in the editor — re-encoding through sharp would silently
|
||||||
const fullBuffer = await sharp(buffer)
|
// re-compress and discard their quality/size choice.
|
||||||
.toFormat('webp')
|
// Fallback (non-webp upload, e.g. editor bypassed): re-encode to WebP q90 as before.
|
||||||
.webp({ quality: 90 }) // High quality for full size
|
const fullDir = path.join(imageDir, 'rezepte', 'full');
|
||||||
.toBuffer();
|
const thumbDir = path.join(imageDir, 'rezepte', 'thumb');
|
||||||
console.log('[ImageProcessing] Full size buffer created, size:', fullBuffer.length, 'bytes');
|
// 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 fullHashedPath = path.join(fullDir, hashedFilename);
|
||||||
const fullUnhashedPath = path.join(imageDir, 'rezepte', 'full', unhashedFilename);
|
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 });
|
console.log('[ImageProcessing] Saving full size to:', { fullHashedPath, fullUnhashedPath });
|
||||||
|
|
||||||
await sharp(fullBuffer).toFile(fullHashedPath);
|
await writeFile(fullHashedPath, fullBuffer);
|
||||||
await sharp(fullBuffer).toFile(fullUnhashedPath);
|
await writeFile(fullUnhashedPath, fullBuffer);
|
||||||
console.log('[ImageProcessing] Full size images saved');
|
console.log('[ImageProcessing] Full size images saved');
|
||||||
|
|
||||||
// Save thumbnail (800px width) - both hashed and unhashed versions
|
// Save thumbnail (800px width) - both hashed and unhashed versions
|
||||||
@@ -158,8 +172,8 @@ export async function processAndSaveRecipeImage(
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes');
|
console.log('[ImageProcessing] Thumbnail buffer created, size:', thumbBuffer.length, 'bytes');
|
||||||
|
|
||||||
const thumbHashedPath = path.join(imageDir, 'rezepte', 'thumb', hashedFilename);
|
const thumbHashedPath = path.join(thumbDir, hashedFilename);
|
||||||
const thumbUnhashedPath = path.join(imageDir, 'rezepte', 'thumb', unhashedFilename);
|
const thumbUnhashedPath = path.join(thumbDir, unhashedFilename);
|
||||||
console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath });
|
console.log('[ImageProcessing] Saving thumbnail to:', { thumbHashedPath, thumbUnhashedPath });
|
||||||
|
|
||||||
await sharp(thumbBuffer).toFile(thumbHashedPath);
|
await sharp(thumbBuffer).toFile(thumbHashedPath);
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ export interface RecipeFormData {
|
|||||||
translationMetadata?: TranslationMetadata;
|
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
|
* Extracts recipe data from FormData
|
||||||
* Handles both simple fields and complex JSON-encoded nested structures
|
* Handles both simple fields and complex JSON-encoded nested structures
|
||||||
|
|||||||
-416458
File diff suppressed because one or more lines are too long
+6
-1
@@ -19,7 +19,12 @@ const config = {
|
|||||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
// 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.
|
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||||
adapter: adapter({
|
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: {
|
prerender: {
|
||||||
// The only intentionally-static pages are /hikes (prerender=true) and
|
// The only intentionally-static pages are /hikes (prerender=true) and
|
||||||
|
|||||||
Reference in New Issue
Block a user